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
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:
@@ -18,38 +18,48 @@ describe('RequestAuth', () => {
|
||||
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 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/writeMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
|
||||
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/)
|
||||
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 swapped = await signed(signer)
|
||||
swapped.set(AUTH_HEADERS.publicKey, other.publicKey)
|
||||
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 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)
|
||||
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)
|
||||
})
|
||||
|
||||
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(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/)
|
||||
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/)
|
||||
const once = await signed(signer, '/p', 'b')
|
||||
await auth.verify(once, '/p', 'b')
|
||||
await expect(auth.verify(once, '/p', 'b')).rejects.toThrow(/Replayed/)
|
||||
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')
|
||||
unknown.set(AUTH_HEADERS.algorithm, 'rot13')
|
||||
await expect(auth.verify(unknown, '/p', 'c')).rejects.toThrow(/Unsupported signature algorithm "rot13"/)
|
||||
|
||||
Reference in New Issue
Block a user