Signed requests: X-Nonce header; composite: sync skips unconfirmed writes
CI / Node 22 (push) Successful in 13s
CI / Node 24 (push) Successful in 13s
CI / Node 26 (push) Successful in 14s
CI / Node 22 (pull_request) Successful in 12s
CI / Node 24 (pull_request) Successful in 13s
CI / Node 26 (pull_request) Successful in 14s
CI / Chromium (push) Successful in 46s
CI / Publish to staging registry (push) Successful in 9s
CI / Chromium (pull_request) Successful in 45s
CI / Publish to staging registry (pull_request) Skipped

- Requests carry a random X-Nonce, signed as ${timestamp}:${nonce}:${path}:${body}. The
  server rejects a nonce the same key already used within the timestamp window. Identical
  requests at the same moment (two tabs, parallel reads) are now distinct, replacing the
  per-process increasing-timestamp workaround.
- Composite: a sync no longer reverts keys with local writes the source hasn't confirmed
  yet (in flight or queued); they briefly disappeared when a sync's scan ran before the
  write reached the source. Pending keys are reloaded from a persistent queue on start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
Claude
2026-10-23 14:50:16 +00:00
parent 7d755cecae
commit cb9ff514a0
6 changed files with 128 additions and 65 deletions
+15 -14
View File
@@ -128,20 +128,19 @@ no own value. A node's own value and its children are independent keys.
The server requires `auth`: either `{ verifiers, maxSkewMs?, now? }`, or `'none'` (open, for local The server requires `auth`: either `{ verifiers, maxSkewMs?, now? }`, or `'none'` (open, for local
development and tests). It can't be left open by omission. development and tests). It can't be left open by omission.
- **Signed requests.** The client's `RequestSigner` signs `${timestamp}:${path}:${body}` (path = - **Signed requests.** The client's `RequestSigner` signs `${timestamp}:${nonce}:${path}:${body}`
URL pathname, body = the exact request body; same scheme as Harvmaster/sync-server-example). (nonce = 16 random bytes as hex, fresh per request; path = URL pathname; body = the exact request
Headers: `X-Public-Key` (lowercase hex), `X-Timestamp` (ms), `X-Signature` (hex) and body). Based on Harvmaster/sync-server-example, plus the nonce. Headers: `X-Public-Key`
`X-Signature-Algorithm`. Every request is signed, and so is each SSE (re)connect, with a fresh (lowercase hex), `X-Timestamp` (ms), `X-Nonce`, `X-Signature` (hex) and `X-Signature-Algorithm`.
timestamp. Every request is signed, and so is each SSE (re)connect, with a fresh timestamp and nonce.
- **Verification (401).** The server picks the `SignatureVerifier` registered for the algorithm - **Verification (401).** The server picks the `SignatureVerifier` registered for the algorithm
(unknown → 401) and checks the timestamp is within `maxSkewMs` (default 60 s). It then rejects a (unknown → 401), checks the nonce format (16–128 characters of `[0-9A-Za-z_-]`) and that the
signature already accepted within that window (replay protection), and verifies the signature timestamp is within `maxSkewMs` (default 60 s). It then rejects a nonce the same public key
over the received path and raw body. The body is parsed only afterwards. already used within that window (replay protection), and verifies the signature over the
- **Unique timestamps.** Ed25519 signatures are deterministic, so identical requests in the same received timestamp, nonce, path and raw body. The body is parsed only afterwards.
millisecond would carry the same signature and the second would be rejected as a replay. - **Why a nonce.** Ed25519 signatures are deterministic, so without it two identical requests
`RequestAuth.headers` makes timestamps strictly increase per public key within a process. Two from one key at the same millisecond (two tabs or devices subscribing to the same path, parallel
processes (e.g. browser tabs) with the same key can still collide, rarely; a nonce in the identical reads) would be indistinguishable, and the second rejected as a replay.
payload would fix that but changes the signing scheme.
- **Algorithms are pluggable.** Built in: `Ed25519Signer` / `Ed25519Verifier` (WebCrypto, so no - **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 dependencies; Node 22+ and current browsers; keys persist as PKCS #8). Others, e.g. secp256k1 via
libauth, implement `RequestSigner` (`algorithm`, `publicKey`, `sign`) and `SignatureVerifier` libauth, implement `RequestSigner` (`algorithm`, `publicKey`, `sign`) and `SignatureVerifier`
@@ -178,7 +177,9 @@ is only appropriate when the client may read all of it.
only where they differ from what the first replica holds. No write tagging or echo tracking is only where they differ from what the first replica holds. No write tagging or echo tracking is
needed: echoes of this client's writes change nothing. needed: echoes of this client's writes change nothing.
- **`sync()`**: replay the queue in order (send, then re-apply locally) → merge-scan the source - **`sync()`**: replay the queue in order (send, then re-apply locally) → merge-scan the source
against the last replica (both key-ordered), applying the difference in batches of 500 → against the last replica (both key-ordered), applying the difference in batches of 500, except
for keys with local writes the source hasn't confirmed yet (in flight or queued; counted per
key, and reloaded from a persistent queue on start), which would otherwise briefly revert →
replay again (writes made meanwhile) → mark `synced`. It runs on start, on `resync` (the stream replay again (writes made meanwhile) → mark `synced`. It runs on start, on `resync` (the stream
reconnected), and `retryMs` after a failure while offline. Called while a sync is running, it reconnected), and `retryMs` after a failure while offline. Called while a sync is running, it
schedules one more after it (shared by all callers meanwhile), so changes after the running schedules one more after it (shared by all callers meanwhile), so changes after the running
+5 -4
View File
@@ -239,10 +239,11 @@ key a value is stored under is authenticated too, so the server can't move value
### Signed requests ### Signed requests
Every request is signed with the user's key: `X-Public-Key`, `X-Timestamp`, `X-Signature` and Every request is signed with the user's key: `X-Public-Key`, `X-Timestamp`, `X-Nonce` (random
`X-Signature-Algorithm` headers, over `${timestamp}:${path}:${body}`. The server checks the per request), `X-Signature` and `X-Signature-Algorithm` headers, over
signature and a 60 s timestamp window (with replay protection), then that every key is the public `${timestamp}:${nonce}:${path}:${body}`. The server checks the signature, a 60 s timestamp
key or under it. Ed25519 is built in (WebCrypto); other algorithms plug in by implementing window and that the nonce wasn't used before (replay protection), then that every key is the
public key or under it. Ed25519 is built in (WebCrypto); other algorithms plug in by implementing
`RequestSigner` / `SignatureVerifier`. `RequestSigner` / `SignatureVerifier`.
### Live updates without the composite ### Live updates without the composite
+43 -14
View File
@@ -51,7 +51,8 @@ export interface KVStorageComposite<S extends Spec = AnySpec> {
* client's own writes therefore changes nothing. * client's own writes therefore changes nothing.
* - A write the source can't take (`StorageUnavailableError`) is applied locally and stored in * - A write the source can't take (`StorageUnavailableError`) is applied locally and stored in
* the `queue` store; later writes queue behind it, so order is kept. * the `queue` store; later writes queue behind it, so order is kept.
* - `sync()` replays the queue, then copies what changed on the source into the replicas. It runs * - `sync()` replays the queue, then copies what changed on the source into the replicas, except
* keys with local writes the source hasn't confirmed yet (in flight or queued). It runs
* on start, when the source reconnects (`resync`), and after a retry delay while offline. * on start, when the source reconnects (`resync`), and after a retry delay while offline.
* - Reads wait for the first sync (until one succeeds, they go to the source). Whether a sync has * - Reads wait for the first sync (until one succeeds, they go to the source). Whether a sync has
* completed is kept in the `queue` store, so with a persistent one a restart reads offline. * completed is kept in the `queue` store, so with a persistent one a restart reads offline.
@@ -77,9 +78,11 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
queued: number queued: number
/** Remote changes are applied one after another. */ /** Remote changes are applied one after another. */
applying: Promise<unknown> applying: Promise<unknown>
/** Keys with local writes the source hasn't confirmed yet (in flight or queued) → count. */
pending: Map<string, number>
retry?: ReturnType<typeof setTimeout> retry?: ReturnType<typeof setTimeout>
offs: OffCallback[] offs: OffCallback[]
} = { synced: false, queued: 0, applying: Promise.resolve(), offs: [] } } = { synced: false, queued: 0, applying: Promise.resolve(), pending: new Map(), offs: [] }
constructor(options: KVStorageCompositeOptions) { constructor(options: KVStorageCompositeOptions) {
super() super()
@@ -140,20 +143,25 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
const keys = changes.map(({ key }) => key) const keys = changes.map(({ key }) => key)
this.checkRange(keys) this.checkRange(keys)
await this.start() await this.start()
// Applied locally first, so the source relaying this write back finds nothing new. // Applied locally first, so the source relaying this write back finds nothing new. Until the
// source confirms it, a sync must not replace it with the source's (older) value.
const previous = await this.replicas[0]!.readMany(keys) const previous = await this.replicas[0]!.readMany(keys)
this.markPending(keys, 1)
await this.apply(changes) await this.apply(changes)
// While syncing or with writes queued, queue this one too so the source sees writes in order. // While syncing or with writes queued, queue this one too so the source sees writes in order.
if (this.state.queued === 0 && !this.state.syncing) { if (this.state.queued > 0 || this.state.syncing) return this.enqueue(changes)
try { try {
return await this.source.writeMany(changes) await this.source.writeMany(changes)
} catch (error) { } catch (error) {
if (!(error instanceof StorageUnavailableError)) { if (error instanceof StorageUnavailableError) return this.enqueue(changes)
await this.apply(keys.map((key, i) => ({ key, value: previous[i] }))) this.markPending(keys, -1)
throw error await this.apply(keys.map((key, i) => ({ key, value: previous[i] })))
} throw error
}
} }
this.markPending(keys, -1)
}
private async enqueue(changes: KVChange[]): Promise<void> {
await this.writes.set(this.ids.generate(), changes) await this.writes.set(this.ids.generate(), changes)
this.state.queued++ this.state.queued++
this.retryLater() this.retryLater()
@@ -167,7 +175,13 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
private async open(): Promise<void> { private async open(): Promise<void> {
this.state.synced = (await this.queue.get('synced')) === true this.state.synced = (await this.queue.get('synced')) === true
for await (const _ of this.writes.keys()) this.state.queued++ for await (const [, changes] of this.writes.entries()) {
this.state.queued++
this.markPending(
(changes as KVChange[]).map(({ key }) => key),
1,
)
}
await this.seed() await this.seed()
this.state.offs.push( this.state.offs.push(
this.source.on('change', (event) => { this.source.on('change', (event) => {
@@ -192,7 +206,10 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
try { try {
await this.replay() await this.replay()
let batch: KVChange[] = [] let batch: KVChange[] = []
for await (const change of this.diff()) if (batch.push(change) >= 500) await this.apply(batch.splice(0), true) for await (const change of this.diff()) {
if (this.state.pending.has(change.key)) continue // a newer local write, not yet sent
if (batch.push(change) >= 500) await this.apply(batch.splice(0), true)
}
await this.apply(batch, true) await this.apply(batch, true)
await this.replay() // writes made during the diff: send them, and re-apply them over it await this.replay() // writes made during the diff: send them, and re-apply them over it
await this.queue.set('synced', true) await this.queue.set('synced', true)
@@ -224,6 +241,10 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
} }
await this.writes.delete(next[0]) await this.writes.delete(next[0])
this.state.queued-- this.state.queued--
this.markPending(
changes.map(({ key }) => key),
-1,
)
} }
} }
@@ -275,6 +296,14 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
await flush() await flush()
} }
private markPending(keys: string[], delta: 1 | -1): void {
for (const key of keys) {
const count = (this.state.pending.get(key) ?? 0) + delta
if (count > 0) this.state.pending.set(key, count)
else this.state.pending.delete(key)
}
}
private retryLater(): void { private retryLater(): void {
clearTimeout(this.state.retry) clearTimeout(this.state.retry)
this.state.retry = setTimeout(() => void this.sync(), this.retryMs) this.state.retry = setTimeout(() => void this.sync(), this.retryMs)
+24 -27
View File
@@ -19,6 +19,7 @@ export interface SignatureVerifier {
export const AUTH_HEADERS = { export const AUTH_HEADERS = {
publicKey: 'X-Public-Key', publicKey: 'X-Public-Key',
timestamp: 'X-Timestamp', timestamp: 'X-Timestamp',
nonce: 'X-Nonce',
signature: 'X-Signature', signature: 'X-Signature',
algorithm: 'X-Signature-Algorithm', algorithm: 'X-Signature-Algorithm',
} as const } as const
@@ -30,17 +31,18 @@ export interface RequestAuthOptions {
} }
/** /**
* Signed-request authentication. The client signs `${timestamp}:${path}:${body}` (path = URL * Signed-request authentication. The client signs `${timestamp}:${nonce}:${path}:${body}` (nonce =
* pathname, body = the exact request body) and sends the public key, timestamp, signature (hex) * random per request, path = URL pathname, body = the exact request body) and sends the public
* and algorithm as headers. The server checks the timestamp is recent, verifies the signature * key, timestamp, nonce, signature (hex) and algorithm as headers. The server checks the
* with the verifier for that algorithm, and rejects a signature it has already seen within the * timestamp is recent, verifies the signature with the verifier for that algorithm, and rejects a
* timestamp window (replay protection). * nonce the same key already used within the timestamp window (replay protection). The nonce also
* keeps identical requests made at the same moment distinct (Ed25519 signatures are deterministic).
*/ */
export class RequestAuth { export class RequestAuth {
private readonly verifiers: Map<string, SignatureVerifier> private readonly verifiers: Map<string, SignatureVerifier>
private readonly maxSkewMs: number private readonly maxSkewMs: number
private readonly now: () => number private readonly now: () => number
/** Signatures accepted within the window → when they expire; insertion order ≈ expiry order. */ /** `<publicKey>:<nonce>` accepted within the window → when it expires; insertion order ≈ expiry order. */
private readonly seen = new Map<string, number>() private readonly seen = new Map<string, number>()
constructor(verifiers: SignatureVerifier[], options: RequestAuthOptions = {}) { constructor(verifiers: SignatureVerifier[], options: RequestAuthOptions = {}) {
@@ -50,27 +52,19 @@ export class RequestAuth {
this.now = options.now ?? Date.now this.now = options.now ?? Date.now
} }
static payload(timestamp: string, path: string, body: string): Uint8Array { static payload(timestamp: string, nonce: string, path: string, body: string): Uint8Array {
return new TextEncoder().encode(`${timestamp}:${path}:${body}`) return new TextEncoder().encode(`${timestamp}:${nonce}:${path}:${body}`)
} }
/** Last timestamp used per public key in this process (see `headers`). */ /** Auth headers for a request to `path` with `body`. */
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>> { static async headers(signer: RequestSigner, path: string, body: string, now: number = Date.now()): Promise<Record<string, string>> {
const time = Math.max(now, (RequestAuth.lastTimestamps.get(signer.publicKey) ?? 0) + 1) const timestamp = String(now)
RequestAuth.lastTimestamps.set(signer.publicKey, time) const nonce = Hex.encode(crypto.getRandomValues(new Uint8Array(16)))
const timestamp = String(time) const signature = await signer.sign(RequestAuth.payload(timestamp, nonce, path, body))
const signature = await signer.sign(RequestAuth.payload(timestamp, path, body))
return { return {
[AUTH_HEADERS.publicKey]: signer.publicKey, [AUTH_HEADERS.publicKey]: signer.publicKey,
[AUTH_HEADERS.timestamp]: timestamp, [AUTH_HEADERS.timestamp]: timestamp,
[AUTH_HEADERS.nonce]: nonce,
[AUTH_HEADERS.signature]: Hex.encode(signature), [AUTH_HEADERS.signature]: Hex.encode(signature),
[AUTH_HEADERS.algorithm]: signer.algorithm, [AUTH_HEADERS.algorithm]: signer.algorithm,
} }
@@ -80,32 +74,35 @@ export class RequestAuth {
async verify(headers: Headers, path: string, body: string): Promise<string> { async verify(headers: Headers, path: string, body: string): Promise<string> {
const publicKey = headers.get(AUTH_HEADERS.publicKey) const publicKey = headers.get(AUTH_HEADERS.publicKey)
const timestamp = headers.get(AUTH_HEADERS.timestamp) const timestamp = headers.get(AUTH_HEADERS.timestamp)
const nonce = headers.get(AUTH_HEADERS.nonce)
const signature = headers.get(AUTH_HEADERS.signature) const signature = headers.get(AUTH_HEADERS.signature)
const algorithm = headers.get(AUTH_HEADERS.algorithm) const algorithm = headers.get(AUTH_HEADERS.algorithm)
if (!publicKey || !timestamp || !signature || !algorithm) throw new AuthError(401, 'Missing authentication headers') if (!publicKey || !timestamp || !nonce || !signature || !algorithm) throw new AuthError(401, 'Missing authentication headers')
const verifier = this.verifiers.get(algorithm) const verifier = this.verifiers.get(algorithm)
if (!verifier) throw new AuthError(401, `Unsupported signature algorithm "${algorithm}"`) if (!verifier) throw new AuthError(401, `Unsupported signature algorithm "${algorithm}"`)
if (!/^[0-9a-f]+$/.test(publicKey)) throw new AuthError(401, 'Public key must be lowercase hex') if (!/^[0-9a-f]+$/.test(publicKey)) throw new AuthError(401, 'Public key must be lowercase hex')
if (!/^[0-9A-Za-z_-]{16,128}$/.test(nonce)) throw new AuthError(401, 'Nonce must be 16-128 characters of [0-9A-Za-z_-]')
const now = this.now() const now = this.now()
const time = Number(timestamp) const time = Number(timestamp)
if (!Number.isInteger(time) || Math.abs(now - time) > this.maxSkewMs) throw new AuthError(401, 'Timestamp outside the accepted window') if (!Number.isInteger(time) || Math.abs(now - time) > this.maxSkewMs) throw new AuthError(401, 'Timestamp outside the accepted window')
this.forgetExpired(now) this.forgetExpired(now)
if (this.seen.has(signature)) throw new AuthError(401, 'Replayed request') const used = `${publicKey}:${nonce}`
if (this.seen.has(used)) throw new AuthError(401, 'Replayed request')
let valid = false let valid = false
try { try {
valid = await verifier.verify(publicKey, RequestAuth.payload(timestamp, path, body), Hex.decode(signature)) valid = await verifier.verify(publicKey, RequestAuth.payload(timestamp, nonce, path, body), Hex.decode(signature))
} catch { } catch {
valid = false valid = false
} }
if (!valid) throw new AuthError(401, 'Invalid signature') if (!valid) throw new AuthError(401, 'Invalid signature')
this.seen.set(signature, time + this.maxSkewMs) this.seen.set(used, time + this.maxSkewMs)
return publicKey return publicKey
} }
private forgetExpired(now: number): void { private forgetExpired(now: number): void {
for (const [signature, expires] of this.seen) { for (const [used, expires] of this.seen) {
if (expires >= now) break if (expires >= now) break
this.seen.delete(signature) this.seen.delete(used)
} }
} }
} }
@@ -257,4 +257,29 @@ describe('KVStorageComposite sync', () => {
['b', 2], ['b', 2],
]) ])
}) })
it('a sync started while a write is on its way to the source does not undo it', async () => {
let release!: () => void
const gate = new Promise<void>((r) => (release = r))
const inner = new KVStorageInMemory()
const source = new KVStorageCounting(inner)
const writeMany = source.writeMany.bind(source)
source.writeMany = async (changes) => {
await gate // a slow request
return writeMany(changes)
}
const replica = new KVStorageInMemory()
const db = new KVStorageComposite({ source, replicas: [replica] })
composites.push(db)
await db.sync()
const writing = db.set('a', 1)
await vi.waitFor(async () => expect(await replica.readMany(['a'])).toEqual([1])) // applied locally, not yet on the source
const syncing = db.sync()
await new Promise((r) => setTimeout(r, 10))
expect(await replica.readMany(['a'])).toEqual([1]) // the sync skips the unconfirmed key
release()
await Promise.all([writing, syncing])
expect(await replica.readMany(['a'])).toEqual([1])
expect(await inner.readMany(['a'])).toEqual([1])
})
}) })
+16 -6
View File
@@ -18,38 +18,48 @@ describe('RequestAuth', () => {
expect(await auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey) expect(await auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey)
}) })
it('rejects tampered bodies, paths and timestamps', async () => { it('rejects tampered bodies, paths, timestamps and nonces', async () => {
const signer = await Ed25519Signer.generate() const signer = await Ed25519Signer.generate()
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW }) const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
await expect(auth.verify(await signed(signer), '/kv/readMany', '{"keys":["b"]}')).rejects.toThrow(/Invalid signature/) 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/) await expect(auth.verify(await signed(signer), '/kv/writeMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
const headers = await signed(signer) const headers = await signed(signer)
headers.set(AUTH_HEADERS.timestamp, String(Number(headers.get(AUTH_HEADERS.timestamp)) + 1)) headers.set(AUTH_HEADERS.timestamp, String(NOW + 1))
await expect(auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/) await expect(auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
const renonced = await signed(signer)
renonced.set(AUTH_HEADERS.nonce, 'f'.repeat(32))
await expect(auth.verify(renonced, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
const other = await Ed25519Signer.generate() const other = await Ed25519Signer.generate()
const swapped = await signed(signer) const swapped = await signed(signer)
swapped.set(AUTH_HEADERS.publicKey, other.publicKey) swapped.set(AUTH_HEADERS.publicKey, other.publicKey)
await expect(auth.verify(swapped, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/) 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 () => { it('accepts identical requests made at the same moment (each has its own nonce)', async () => {
const signer = await Ed25519Signer.generate() const signer = await Ed25519Signer.generate()
const sameKey = await Ed25519Signer.fromPrivateKey(await signer.exportPrivateKey()) const sameKey = await Ed25519Signer.fromPrivateKey(await signer.exportPrivateKey())
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW }) const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
const requests = await Promise.all([signed(signer), signed(signer), signed(sameKey)]) 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) expect(new Set(requests.map((h) => h.get(AUTH_HEADERS.timestamp)))).toEqual(new Set([String(NOW)]))
expect(new Set(requests.map((h) => h.get(AUTH_HEADERS.nonce))).size).toBe(3)
for (const headers of requests) expect(await auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey) 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 () => { it('rejects old or future timestamps, replays, missing headers and unknown algorithms', async () => {
const signer = await Ed25519Signer.generate() const signer = await Ed25519Signer.generate()
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW, maxSkewMs: 1000 }) const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW, maxSkewMs: 1000 })
await expect(auth.verify(await signed(await Ed25519Signer.generate(), '/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(signer, '/p', 'b', NOW + 5000), '/p', 'b')).rejects.toThrow(/window/)
const once = await signed(signer, '/p', 'b') const once = await signed(signer, '/p', 'b')
await auth.verify(once, '/p', 'b') await auth.verify(once, '/p', 'b')
await expect(auth.verify(once, '/p', 'b')).rejects.toThrow(/Replayed/) await expect(auth.verify(once, '/p', 'b')).rejects.toThrow(/Replayed/)
await expect(auth.verify(new Headers(), '/p', 'b')).rejects.toBeInstanceOf(AuthError) await expect(auth.verify(new Headers(), '/p', 'b')).rejects.toBeInstanceOf(AuthError)
const noNonce = await signed(signer, '/p', 'd')
noNonce.delete(AUTH_HEADERS.nonce)
await expect(auth.verify(noNonce, '/p', 'd')).rejects.toThrow(/Missing/)
const shortNonce = await signed(signer, '/p', 'e')
shortNonce.set(AUTH_HEADERS.nonce, 'abc')
await expect(auth.verify(shortNonce, '/p', 'e')).rejects.toThrow(/Nonce/)
const unknown = await signed(signer, '/p', 'c') const unknown = await signed(signer, '/p', 'c')
unknown.set(AUTH_HEADERS.algorithm, 'rot13') unknown.set(AUTH_HEADERS.algorithm, 'rot13')
await expect(auth.verify(unknown, '/p', 'c')).rejects.toThrow(/Unsupported signature algorithm "rot13"/) await expect(auth.verify(unknown, '/p', 'c')).rejects.toThrow(/Unsupported signature algorithm "rot13"/)