Skip to content

Commit e02ca7d

Browse files
audit fixes (#131)
* audit fixes * format * adjust form validation timing
1 parent fce45f1 commit e02ca7d

34 files changed

Lines changed: 1043 additions & 165 deletions

docker-compose.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
services:
22
redis:
33
image: redis:latest
4+
# Bound memory so a flood of secrets can't exhaust the host. Secrets are
5+
# TTL'd and must never be silently dropped, so use noeviction: once the cap
6+
# is hit, new writes are rejected (surfacing pressure) rather than evicting
7+
# live secrets. Tune REDIS_MAXMEMORY to the host.
8+
command:
9+
- redis-server
10+
- --maxmemory
11+
- ${REDIS_MAXMEMORY:-256mb}
12+
- --maxmemory-policy
13+
- noeviction
14+
- --save
15+
- '900'
16+
- '1'
17+
- --loglevel
18+
- warning
419
volumes:
520
- redis_data:/data
621

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"typecheck": "turbo typecheck",
1313
"format": "prettier --write .",
1414
"format:check": "prettier --check .",
15-
"redis": "docker compose run -p 6379:6379 -v ./redis-data:/data redis redis-server --save 1 1 --loglevel warning"
15+
"redis": "docker compose run --rm -p 6379:6379 -v ./redis-data:/data redis redis-server --save 1 1 --loglevel notice"
1616
},
1717
"devDependencies": {
1818
"@eslint/js": "^9.39.2",

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@noble/hashes": "^1.8.0",
2929
"@noble/post-quantum": "^0.4.1",
3030
"buffer": "^6.0.3",
31+
"ipaddr.js": "^2.4.0",
3132
"pako": "^2.1.0",
3233
"zod": "^4.1.5"
3334
},

packages/core/src/client.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { Client } from './client';
2+
3+
// Minimal in-memory stand-in for the vault API so we can exercise the full
4+
// create -> read crypto pipeline (both layers, verification hash, versioned key)
5+
// without a real server or browser.
6+
type Entry = { c: string; h: string; m: unknown; b: boolean };
7+
8+
const makeFakeServer = () => {
9+
const store = new Map<string, Entry>();
10+
let counter = 0;
11+
12+
const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => {
13+
const u = new URL(typeof url === 'string' ? url : url instanceof URL ? url.href : url.url);
14+
const method = init?.method ?? 'GET';
15+
16+
if (u.pathname === '/vault' && method === 'POST') {
17+
const body = JSON.parse(init!.body as string);
18+
const id = `id${counter++}`;
19+
store.set(id, { c: body.c, h: body.h, m: body.m, b: body.b });
20+
return new Response(JSON.stringify({ id, dt: 'delete-token' }), { status: 201 });
21+
}
22+
23+
const match = u.pathname.match(/^\/vault\/(.+)$/);
24+
if (match && method === 'GET') {
25+
const entry = store.get(match[1]);
26+
if (!entry) return new Response(null, { status: 404 });
27+
const h = u.searchParams.get('h');
28+
if (h !== entry.h) return new Response(null, { status: 400 });
29+
return new Response(
30+
JSON.stringify({ c: entry.c, b: entry.b, ttl: 1000, cd: 0, m: entry.m }),
31+
{ status: 200 },
32+
);
33+
}
34+
35+
return new Response(null, { status: 404 });
36+
}) as unknown as typeof fetch;
37+
38+
return { fetchImpl, store };
39+
};
40+
41+
describe('Client create -> read round trip', () => {
42+
const origFetch = globalThis.fetch;
43+
let server: ReturnType<typeof makeFakeServer>;
44+
let client: Client;
45+
46+
beforeEach(() => {
47+
server = makeFakeServer();
48+
globalThis.fetch = server.fetchImpl;
49+
client = new Client({ apiUrl: 'http://vault.test' });
50+
});
51+
52+
afterEach(() => {
53+
globalThis.fetch = origFetch;
54+
});
55+
56+
it('round-trips content without a password (fast path, no version prefix)', async () => {
57+
const created = await client.create({ c: 'hello world', ttl: 1000, b: false });
58+
expect(created.key.startsWith('2.')).toBe(false);
59+
60+
const read = await client.read(created.id, created.key);
61+
expect(read.c).toBe('hello world');
62+
});
63+
64+
it('round-trips content with a password (versioned key + argon2 hash)', async () => {
65+
const created = await client.create({ c: 'the secret', p: 'hunter2', ttl: 1000, b: false });
66+
expect(created.key.startsWith('2.')).toBe(true);
67+
68+
const read = await client.read(created.id, created.key, 'hunter2');
69+
expect(read.c).toBe('the secret');
70+
});
71+
72+
it('rejects a read with the wrong password', async () => {
73+
const created = await client.create({ c: 'the secret', p: 'hunter2', ttl: 1000, b: false });
74+
await expect(client.read(created.id, created.key, 'wrong')).rejects.toThrow();
75+
});
76+
77+
it('rejects a read missing the required password', async () => {
78+
const created = await client.create({ c: 'the secret', p: 'hunter2', ttl: 1000, b: false });
79+
await expect(client.read(created.id, created.key)).rejects.toThrow();
80+
});
81+
82+
it('stores the asymmetric password algorithm only when a password is set', async () => {
83+
const withPw = await client.create({ c: 'x', p: 'pw', ttl: 1000, b: false });
84+
const withoutPw = await client.create({ c: 'y', ttl: 1000, b: false });
85+
86+
const pwEntry = server.store.get(withPw.id)!.m as {
87+
encryption: { algorithm: string; passwordAlgorithm?: string };
88+
};
89+
const noPwEntry = server.store.get(withoutPw.id)!.m as {
90+
encryption: { algorithm: string; passwordAlgorithm?: string };
91+
};
92+
93+
expect(pwEntry.encryption.passwordAlgorithm).toBe('ml-kem-768-argon2');
94+
expect(noPwEntry.encryption.passwordAlgorithm).toBeUndefined();
95+
expect(noPwEntry.encryption.algorithm).toBe('ml-kem-768-2');
96+
});
97+
});

packages/core/src/client.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
ReadVaultResponse,
77
} from './api';
88
import { generateRandomString } from './random';
9-
import { sha512 } from './hash';
9+
import { KEY_VERSION_2_PREFIX, deriveVerificationHash, parseKey } from './verification';
1010
import { encryptionRegistry, compressionRegistry, validateMetadata } from './encryption/registry';
1111
import { ProcessingMetadata } from './vault';
1212
import { gcm } from './encryption';
@@ -63,6 +63,25 @@ export class Client {
6363
return processed;
6464
}
6565

66+
// Metadata for the inner (URL key) layer: carries compression + the primary
67+
// encryption algorithm.
68+
private keyLayerMetadata(metadata: ProcessingMetadata): ProcessingMetadata {
69+
return {
70+
compression: metadata.compression,
71+
encryption: { algorithm: metadata.encryption.algorithm },
72+
};
73+
}
74+
75+
// Metadata for the outer (user password) layer: encryption only, no
76+
// compression (compression happens once, on the inner layer).
77+
private passwordLayerMetadata(metadata: ProcessingMetadata): ProcessingMetadata {
78+
return {
79+
encryption: {
80+
algorithm: metadata.encryption.passwordAlgorithm ?? metadata.encryption.algorithm,
81+
},
82+
};
83+
}
84+
6685
private async recoverContent(
6786
encoded: string,
6887
key: string,
@@ -103,21 +122,45 @@ export class Client {
103122
async create(
104123
input: Omit<CreateVaultRequest, 'h' | 'm'> & { p?: string; m?: ProcessingMetadata },
105124
): Promise<CreateVaultResponse & { key: string; hash: string }> {
106-
const key = await generateRandomString(this.keyLength);
125+
const rawKey = await generateRandomString(this.keyLength);
126+
const hasPassword = input.p !== undefined && input.p !== '';
107127

128+
// Fast KDF for the high-entropy URL key layer; memory-hard Argon2id only for
129+
// the user-password layer, and only when a password is actually set.
108130
const metadata: ProcessingMetadata = input.m ?? {
109131
compression: {
110132
algorithm: 'zlib:pako',
111133
},
112134
encryption: {
113135
algorithm: 'ml-kem-768-2',
136+
passwordAlgorithm: hasPassword ? 'ml-kem-768-argon2' : undefined,
114137
},
115138
};
116139

117-
const processed = await this.processContent(input.c, metadata, key).then((r) =>
118-
!input.p ? r : this.processContent(r, metadata, input.p),
119-
);
120-
const hash = sha512(key + (input.p ?? ''));
140+
let processed: string;
141+
if (!hasPassword) {
142+
processed = await this.processContent(input.c, metadata, rawKey);
143+
} else if (metadata.encryption.passwordAlgorithm) {
144+
// Asymmetric: compress + key-encrypt inner, then password-encrypt outer.
145+
const inner = await this.processContent(input.c, this.keyLayerMetadata(metadata), rawKey);
146+
processed = await this.processContent(
147+
inner,
148+
this.passwordLayerMetadata(metadata),
149+
input.p as string,
150+
);
151+
} else {
152+
// Legacy symmetric pipeline (custom metadata without passwordAlgorithm).
153+
const inner = await this.processContent(input.c, metadata, rawKey);
154+
processed = await this.processContent(inner, metadata, input.p as string);
155+
}
156+
157+
// Only password-protected secrets need the Argon2id (v2) verification hash;
158+
// a bare high-entropy key is not brute-forceable, so it keeps the fast hash.
159+
// The version prefix on the shared key tells the reader which scheme to use.
160+
const key = hasPassword ? `${KEY_VERSION_2_PREFIX}${rawKey}` : rawKey;
161+
const hash = hasPassword
162+
? await deriveVerificationHash('v2', rawKey, input.p)
163+
: await deriveVerificationHash('legacy', rawKey);
121164

122165
const response = await fetch(`${this.apiUrl}/vault`, {
123166
method: 'POST',
@@ -156,7 +199,8 @@ export class Client {
156199
}
157200

158201
async read(id: string, key: string, password?: string) {
159-
const h = sha512(key + (password ?? ''));
202+
const { scheme, rawKey } = parseKey(key);
203+
const h = await deriveVerificationHash(scheme, rawKey, password);
160204
const res = await fetch(`${this.apiUrl}/vault/${id}?h=${h}`, {
161205
headers: this.getHeaders(),
162206
});
@@ -170,11 +214,18 @@ export class Client {
170214
}
171215

172216
const data = await (res.json() as Promise<ReadVaultResponse>);
173-
const decrypted = password
174-
? await this.recoverContent(data.c, password, data.m).then((d) =>
175-
this.recoverContent(d, key, data.m),
176-
)
177-
: await this.recoverContent(data.c, key, data.m);
217+
let decrypted: string;
218+
if (!password) {
219+
decrypted = await this.recoverContent(data.c, rawKey, data.m);
220+
} else if (data.m?.encryption?.passwordAlgorithm) {
221+
// Asymmetric: password-decrypt outer, then key-decrypt + decompress inner.
222+
const tmp = await this.recoverContent(data.c, password, this.passwordLayerMetadata(data.m));
223+
decrypted = await this.recoverContent(tmp, rawKey, this.keyLayerMetadata(data.m));
224+
} else {
225+
// Legacy symmetric pipeline.
226+
const tmp = await this.recoverContent(data.c, password, data.m);
227+
decrypted = await this.recoverContent(tmp, rawKey, data.m);
228+
}
178229

179230
return {
180231
c: decrypted,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export * as gcm from './gcm';
22
export * as mlkem from './mlkem';
33
export * as mlkem2 from './mlkem2';
4+
export * as mlkemArgon2 from './mlkem_argon2';
5+
export * as machine from './machine';
46

57
export * from './encryption';
68
export * from './registry';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { encrypt, decrypt } from './machine';
2+
import { encrypt as gcmEncrypt } from './gcm';
3+
4+
describe('machine cipher', () => {
5+
const key = 'a-high-entropy-machine-key-0123456789';
6+
7+
it('round-trips content', async () => {
8+
const plaintext = 'https://hooks.example.com/webhook?x=1';
9+
const enc = await encrypt(plaintext, key);
10+
expect(enc.startsWith('m1:')).toBe(true);
11+
expect(await decrypt(enc, key)).toBe(plaintext);
12+
});
13+
14+
it('produces distinct ciphertexts for the same input (random salt+iv)', async () => {
15+
const a = await encrypt('same', key);
16+
const b = await encrypt('same', key);
17+
expect(a).not.toBe(b);
18+
});
19+
20+
it('fails to decrypt with the wrong key', async () => {
21+
const enc = await encrypt('secret', key);
22+
await expect(decrypt(enc, 'wrong-key')).rejects.toThrow();
23+
});
24+
25+
it('transparently decrypts legacy PBKDF2 (gcm) ciphertext', async () => {
26+
const plaintext = '10.0.0.0/8,192.168.1.1';
27+
const legacy = await gcmEncrypt(plaintext, key);
28+
expect(legacy.startsWith('m1:')).toBe(false);
29+
expect(await decrypt(legacy, key)).toBe(plaintext);
30+
});
31+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Symmetric encryption for data protected by a static, high-entropy *machine*
2+
// key (the server's ENCRYPTION_KEY) rather than a user-chosen password.
3+
//
4+
// The password-oriented ciphers (gcm/mlkem*) deliberately run an expensive KDF
5+
// (PBKDF2 at 2^19 iterations) to resist brute-forcing weak human passwords.
6+
// That cost is pointless for a machine key — there is no low-entropy secret to
7+
// stretch — and it runs on the server's hot path (every vault set/get/del and
8+
// every webhook enqueue/dequeue). Here we derive the AES key with HKDF, which is
9+
// fast and appropriate for a key that is already uniformly random.
10+
//
11+
// Format (base64, prefixed with a scheme tag for forward/backward routing):
12+
// "m1:" || base64( salt[16] || iv[12] || aes-256-gcm-ciphertext )
13+
//
14+
// Values without the "m1:" prefix are legacy PBKDF2-format ciphertexts and are
15+
// transparently decrypted via the gcm module so existing vault entries keep
16+
// working.
17+
18+
import { gcm as aesGcm } from '@noble/ciphers/aes';
19+
import { hkdf } from '@noble/hashes/hkdf';
20+
import { sha256 } from '@noble/hashes/sha2';
21+
import { randomBytes, concatBytes, utf8ToBytes } from '@noble/hashes/utils';
22+
import { Buffer } from '../buffer';
23+
import { Decrypt, Encrypt, DecryptError, EncryptError } from './encryption';
24+
import { decrypt as gcmDecrypt } from './gcm';
25+
26+
const SALT_LENGTH = 16;
27+
const IV_LENGTH = 12;
28+
const KEY_LENGTH = 32;
29+
const SCHEME_PREFIX = 'm1:';
30+
const HKDF_INFO = utf8ToBytes('crypt.fyi/machine-metadata');
31+
32+
const deriveKey = (key: string, salt: Uint8Array): Uint8Array =>
33+
hkdf(sha256, utf8ToBytes(key), salt, HKDF_INFO, KEY_LENGTH);
34+
35+
export const encrypt: Encrypt = async (content, key) => {
36+
try {
37+
const salt = randomBytes(SALT_LENGTH);
38+
const iv = randomBytes(IV_LENGTH);
39+
const derivedKey = deriveKey(key, salt);
40+
const cipher = aesGcm(derivedKey, iv);
41+
const encrypted = cipher.encrypt(utf8ToBytes(content));
42+
43+
const result = concatBytes(salt, iv, encrypted);
44+
return SCHEME_PREFIX + Buffer.from(result).toString('base64');
45+
} catch (error) {
46+
throw new EncryptError(error);
47+
}
48+
};
49+
50+
export const decrypt: Decrypt = async (encryptedContent, key) => {
51+
// Legacy entries were written with the password-KDF (PBKDF2) gcm cipher.
52+
if (!encryptedContent.startsWith(SCHEME_PREFIX)) {
53+
return gcmDecrypt(encryptedContent, key);
54+
}
55+
56+
try {
57+
const data = Buffer.from(encryptedContent.slice(SCHEME_PREFIX.length), 'base64');
58+
const salt = data.subarray(0, SALT_LENGTH);
59+
const iv = data.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
60+
const ciphertext = data.subarray(SALT_LENGTH + IV_LENGTH);
61+
62+
const derivedKey = deriveKey(key, salt);
63+
const cipher = aesGcm(derivedKey, iv);
64+
const decrypted = cipher.decrypt(ciphertext);
65+
return new TextDecoder().decode(decrypted);
66+
} catch (error) {
67+
throw new DecryptError(error);
68+
}
69+
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { encrypt, decrypt } from './mlkem_argon2';
2+
3+
describe('ml-kem-768-argon2', () => {
4+
it('round-trips content with a password', async () => {
5+
const enc = await encrypt('top secret', 'correct horse battery staple');
6+
expect(await decrypt(enc, 'correct horse battery staple')).toBe('top secret');
7+
});
8+
9+
it('produces distinct ciphertexts for the same input', async () => {
10+
const a = await encrypt('same', 'pw');
11+
const b = await encrypt('same', 'pw');
12+
expect(a).not.toBe(b);
13+
});
14+
15+
it('fails to decrypt with the wrong password', async () => {
16+
const enc = await encrypt('secret', 'right');
17+
await expect(decrypt(enc, 'wrong')).rejects.toThrow();
18+
});
19+
});

0 commit comments

Comments
 (0)