diff --git a/DESIGN.md b/DESIGN.md index dae767e..2958383 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -137,6 +137,11 @@ development and tests). It can't be left open by omission. (unknown → 401) and checks the timestamp is within `maxSkewMs` (default 60 s). It then rejects a signature already accepted within that window (replay protection), and verifies the signature over the received path and raw body. The body is parsed only afterwards. +- **Unique timestamps.** Ed25519 signatures are deterministic, so identical requests in the same + millisecond would carry the same signature and the second would be rejected as a replay. + `RequestAuth.headers` makes timestamps strictly increase per public key within a process. Two + processes (e.g. browser tabs) with the same key can still collide, rarely; a nonce in the + payload would fix that but changes the signing scheme. - **Algorithms are pluggable.** Built in: `Ed25519Signer` / `Ed25519Verifier` (WebCrypto, so no dependencies; Node 22+ and current browsers; keys persist as PKCS #8). Others, e.g. secp256k1 via libauth, implement `RequestSigner` (`algorithm`, `publicKey`, `sign`) and `SignatureVerifier` diff --git a/src/utils/request-auth.ts b/src/utils/request-auth.ts index 87737be..c94f39c 100644 --- a/src/utils/request-auth.ts +++ b/src/utils/request-auth.ts @@ -54,9 +54,19 @@ export class RequestAuth { return new TextEncoder().encode(`${timestamp}:${path}:${body}`) } - /** Auth headers for a request to `path` with `body`. */ + /** Last timestamp used per public key in this process (see `headers`). */ + private static readonly lastTimestamps = new Map() + + /** + * Auth headers for a request to `path` with `body`. Timestamps per public key strictly increase + * within a process: Ed25519 signatures are deterministic, so two identical requests in the same + * millisecond would otherwise carry the same signature, and the second would be rejected as a + * replay. + */ static async headers(signer: RequestSigner, path: string, body: string, now: number = Date.now()): Promise> { - const timestamp = String(now) + const time = Math.max(now, (RequestAuth.lastTimestamps.get(signer.publicKey) ?? 0) + 1) + RequestAuth.lastTimestamps.set(signer.publicKey, time) + const timestamp = String(time) const signature = await signer.sign(RequestAuth.payload(timestamp, path, body)) return { [AUTH_HEADERS.publicKey]: signer.publicKey, diff --git a/test/utils/request-auth.test.ts b/test/utils/request-auth.test.ts index 532f10d..706f07c 100644 --- a/test/utils/request-auth.test.ts +++ b/test/utils/request-auth.test.ts @@ -24,7 +24,7 @@ describe('RequestAuth', () => { await expect(auth.verify(await signed(signer), '/kv/readMany', '{"keys":["b"]}')).rejects.toThrow(/Invalid signature/) await expect(auth.verify(await signed(signer), '/kv/writeMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/) const headers = await signed(signer) - headers.set(AUTH_HEADERS.timestamp, String(NOW + 1)) + headers.set(AUTH_HEADERS.timestamp, String(Number(headers.get(AUTH_HEADERS.timestamp)) + 1)) await expect(auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/) const other = await Ed25519Signer.generate() const swapped = await signed(signer) @@ -32,11 +32,20 @@ describe('RequestAuth', () => { await expect(auth.verify(swapped, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/) }) + it('accepts identical requests made in the same millisecond (timestamps per key increase)', async () => { + const signer = await Ed25519Signer.generate() + const sameKey = await Ed25519Signer.fromPrivateKey(await signer.exportPrivateKey()) + const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW }) + const requests = await Promise.all([signed(signer), signed(signer), signed(sameKey)]) + expect(new Set(requests.map((h) => h.get(AUTH_HEADERS.signature))).size).toBe(3) + for (const headers of requests) expect(await auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey) + }) + it('rejects old or future timestamps, replays, missing headers and unknown algorithms', async () => { const signer = await Ed25519Signer.generate() const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW, maxSkewMs: 1000 }) - await expect(auth.verify(await signed(signer, '/p', 'b', NOW - 5000), '/p', 'b')).rejects.toThrow(/window/) - await expect(auth.verify(await signed(signer, '/p', 'b', NOW + 5000), '/p', 'b')).rejects.toThrow(/window/) + await expect(auth.verify(await signed(await Ed25519Signer.generate(), '/p', 'b', NOW - 5000), '/p', 'b')).rejects.toThrow(/window/) + await expect(auth.verify(await signed(await Ed25519Signer.generate(), '/p', 'b', NOW + 5000), '/p', 'b')).rejects.toThrow(/window/) const once = await signed(signer, '/p', 'b') await auth.verify(once, '/p', 'b') await expect(auth.verify(once, '/p', 'b')).rejects.toThrow(/Replayed/)