Simplified layers, authenticated sync, composite storage and hash-keyed events #2
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/)
|
||||
|
||||
Reference in New Issue
Block a user