CI / Node 22 (push) Successful in 13s
CI / Node 24 (push) Successful in 13s
CI / Node 26 (push) Successful in 14s
CI / Node 24 (pull_request) Successful in 12s
CI / Chromium (pull_request) Successful in 48s
CI / Publish to staging registry (pull_request) Skipped
CI / Node 22 (pull_request) Successful in 13s
CI / Node 26 (pull_request) Successful in 12s
CI / Chromium (push) Successful in 47s
CI / Publish to staging registry (push) Successful in 10s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
79 lines
4.8 KiB
TypeScript
79 lines
4.8 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { AUTH_HEADERS, Ed25519Signer, Ed25519Verifier, RequestAuth, type RequestSigner, type SignatureVerifier } from '../../src/utils/request-auth.js'
|
|
import { AuthError } from '../../src/utils/errors.js'
|
|
|
|
const NOW = 1_700_000_000_000
|
|
|
|
async function signed(signer: RequestSigner, path = '/kv/readMany', body = '{"keys":["a"]}', at = NOW) {
|
|
return new Headers(await RequestAuth.headers(signer, path, body, at))
|
|
}
|
|
|
|
describe('RequestAuth', () => {
|
|
it('accepts a correctly signed request and returns the public key', async () => {
|
|
const signer = await Ed25519Signer.generate()
|
|
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
|
|
const headers = await signed(signer)
|
|
expect(headers.get(AUTH_HEADERS.algorithm)).toBe('ed25519')
|
|
expect(signer.publicKey).toMatch(/^[0-9a-f]{64}$/)
|
|
expect(await auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey)
|
|
})
|
|
|
|
it('rejects tampered bodies, paths and timestamps', 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))
|
|
await expect(auth.verify(headers, '/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 () => {
|
|
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(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/)
|
|
await expect(auth.verify(new Headers(), '/p', 'b')).rejects.toBeInstanceOf(AuthError)
|
|
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"/)
|
|
})
|
|
|
|
it('restores an Ed25519 key from its exported private key', async () => {
|
|
const signer = await Ed25519Signer.generate()
|
|
const restored = await Ed25519Signer.fromPrivateKey(await signer.exportPrivateKey())
|
|
expect(restored.publicKey).toBe(signer.publicKey)
|
|
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
|
|
expect(await auth.verify(await signed(restored), '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey)
|
|
})
|
|
|
|
it('supports other algorithms through the verifier registry', async () => {
|
|
// A toy algorithm (signature = sha256(publicKey + payload)) standing in for e.g. secp256k1 via libauth.
|
|
const digest = async (publicKey: string, payload: Uint8Array) =>
|
|
new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array([...new TextEncoder().encode(publicKey), ...payload])))
|
|
const toySigner: RequestSigner = { algorithm: 'toy', publicKey: 'abcd', sign: (payload) => digest('abcd', payload) }
|
|
const toyVerifier: SignatureVerifier = {
|
|
algorithm: 'toy',
|
|
verify: async (publicKey, payload, signature) => (await digest(publicKey, payload)).every((byte, i) => byte === signature[i]),
|
|
}
|
|
const auth = new RequestAuth([new Ed25519Verifier(), toyVerifier], { now: () => NOW })
|
|
expect(await auth.verify(await signed(toySigner), '/kv/readMany', '{"keys":["a"]}')).toBe('abcd')
|
|
})
|
|
})
|