From 7d755cecae4c80f567190a43611dd80ab9b4f6b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 14:36:25 +0000 Subject: [PATCH] Signed requests: strictly increasing timestamps per key Ed25519 signatures are deterministic, so two identical requests from one key in the same millisecond (e.g. two devices subscribing to the same path, or parallel identical reads) had the same signature, and the server rejected the second as a replay. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE --- DESIGN.md | 5 +++++ src/utils/request-auth.ts | 14 ++++++++++++-- test/utils/request-auth.test.ts | 15 ++++++++++++--- 3 files changed, 29 insertions(+), 5 deletions(-) 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/)