- RequestAuth: clients sign `${timestamp}:${path}:${body}` (as in
sync-server-example); headers carry public key, timestamp, signature and
algorithm. Pluggable signers/verifiers; Ed25519 via WebCrypto built in.
Timestamp window and replay protection.
- KVStorageHttpServer requires auth ('none' must be explicit). With auth, every
key, scan range and subscription path must be under the caller's public key.
- POST /events with { paths }: subscribers only receive (and on resume only
replay) changes under their authorized paths.
- KVStorageHttp: signer option; subscribe(path)/unsubscribe(path)/close()
replace watch(); 401/403 reject instead of retrying.
- KVStorageComposite: prefix option scopes sync, cache and subscription.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
70 lines
4.1 KiB
TypeScript
70 lines
4.1 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(NOW + 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('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/)
|
|
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')
|
|
})
|
|
})
|