Simplified layers, authenticated sync, composite storage and hash-keyed events #2

Open
Harvmaster wants to merge 11 commits from sync into main
3 changed files with 29 additions and 5 deletions
Showing only changes of commit 7d755cecae - Show all commits
+5
View File
@@ -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`
+12 -2
View File
@@ -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<string, number>()
/**
* 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<Record<string, string>> {
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,
+12 -3
View File
@@ -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/)