- 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
151 lines
6.5 KiB
TypeScript
151 lines
6.5 KiB
TypeScript
import { AuthError } from './errors.js'
|
|
import { Hex } from './hex.js'
|
|
|
|
/** Signs requests. Implement this to use another algorithm (e.g. secp256k1 via libauth). */
|
|
export interface RequestSigner {
|
|
/** Algorithm name sent in `X-Signature-Algorithm`; the server must have a matching verifier. */
|
|
readonly algorithm: string
|
|
/** Public key as lowercase hex. Also the owner segment of the caller's keys (`/<publicKey>/...`). */
|
|
readonly publicKey: string
|
|
sign(payload: Uint8Array): Promise<Uint8Array>
|
|
}
|
|
|
|
/** Verifies signatures for one algorithm. */
|
|
export interface SignatureVerifier {
|
|
readonly algorithm: string
|
|
verify(publicKey: string, payload: Uint8Array, signature: Uint8Array): Promise<boolean>
|
|
}
|
|
|
|
export const AUTH_HEADERS = {
|
|
publicKey: 'X-Public-Key',
|
|
timestamp: 'X-Timestamp',
|
|
signature: 'X-Signature',
|
|
algorithm: 'X-Signature-Algorithm',
|
|
} as const
|
|
|
|
export interface RequestAuthOptions {
|
|
/** Accepted difference between the request timestamp and the server clock. Default 60 000 ms. */
|
|
maxSkewMs?: number
|
|
now?: () => number
|
|
}
|
|
|
|
/**
|
|
* Signed-request authentication. The client signs `${timestamp}:${path}:${body}` (path = URL
|
|
* pathname, body = the exact request body) and sends the public key, timestamp, signature (hex)
|
|
* and algorithm as headers. The server checks the timestamp is recent, verifies the signature
|
|
* with the verifier for that algorithm, and rejects a signature it has already seen within the
|
|
* timestamp window (replay protection).
|
|
*/
|
|
export class RequestAuth {
|
|
private readonly verifiers: Map<string, SignatureVerifier>
|
|
private readonly maxSkewMs: number
|
|
private readonly now: () => number
|
|
/** Signatures accepted within the window → when they expire; insertion order ≈ expiry order. */
|
|
private readonly seen = new Map<string, number>()
|
|
|
|
constructor(verifiers: SignatureVerifier[], options: RequestAuthOptions = {}) {
|
|
if (verifiers.length === 0) throw new TypeError('RequestAuth needs at least one verifier')
|
|
this.verifiers = new Map(verifiers.map((v) => [v.algorithm, v]))
|
|
this.maxSkewMs = options.maxSkewMs ?? 60_000
|
|
this.now = options.now ?? Date.now
|
|
}
|
|
|
|
static payload(timestamp: string, path: string, body: string): Uint8Array {
|
|
return new TextEncoder().encode(`${timestamp}:${path}:${body}`)
|
|
}
|
|
|
|
/** Auth headers for a request to `path` with `body`. */
|
|
static async headers(signer: RequestSigner, path: string, body: string, now: number = Date.now()): Promise<Record<string, string>> {
|
|
const timestamp = String(now)
|
|
const signature = await signer.sign(RequestAuth.payload(timestamp, path, body))
|
|
return {
|
|
[AUTH_HEADERS.publicKey]: signer.publicKey,
|
|
[AUTH_HEADERS.timestamp]: timestamp,
|
|
[AUTH_HEADERS.signature]: Hex.encode(signature),
|
|
[AUTH_HEADERS.algorithm]: signer.algorithm,
|
|
}
|
|
}
|
|
|
|
/** Returns the caller's public key, or throws `AuthError` (401). */
|
|
async verify(headers: Headers, path: string, body: string): Promise<string> {
|
|
const publicKey = headers.get(AUTH_HEADERS.publicKey)
|
|
const timestamp = headers.get(AUTH_HEADERS.timestamp)
|
|
const signature = headers.get(AUTH_HEADERS.signature)
|
|
const algorithm = headers.get(AUTH_HEADERS.algorithm)
|
|
if (!publicKey || !timestamp || !signature || !algorithm) throw new AuthError(401, 'Missing authentication headers')
|
|
const verifier = this.verifiers.get(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')
|
|
const now = this.now()
|
|
const time = Number(timestamp)
|
|
if (!Number.isInteger(time) || Math.abs(now - time) > this.maxSkewMs) throw new AuthError(401, 'Timestamp outside the accepted window')
|
|
this.forgetExpired(now)
|
|
if (this.seen.has(signature)) throw new AuthError(401, 'Replayed request')
|
|
let valid = false
|
|
try {
|
|
valid = await verifier.verify(publicKey, RequestAuth.payload(timestamp, path, body), Hex.decode(signature))
|
|
} catch {
|
|
valid = false
|
|
}
|
|
if (!valid) throw new AuthError(401, 'Invalid signature')
|
|
this.seen.set(signature, time + this.maxSkewMs)
|
|
return publicKey
|
|
}
|
|
|
|
private forgetExpired(now: number): void {
|
|
for (const [signature, expires] of this.seen) {
|
|
if (expires >= now) break
|
|
this.seen.delete(signature)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Ed25519 signer using WebCrypto (Node 22+ and current browsers; no dependencies). */
|
|
export class Ed25519Signer implements RequestSigner {
|
|
readonly algorithm = 'ed25519'
|
|
|
|
private constructor(
|
|
private readonly privateKey: CryptoKey,
|
|
readonly publicKey: string,
|
|
) {}
|
|
|
|
/** A new random key pair. */
|
|
static async generate(): Promise<Ed25519Signer> {
|
|
const pair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as CryptoKeyPair
|
|
return new Ed25519Signer(pair.privateKey, Hex.encode(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))))
|
|
}
|
|
|
|
/** Restores a key exported with `exportPrivateKey()` (PKCS #8). */
|
|
static async fromPrivateKey(pkcs8: Uint8Array): Promise<Ed25519Signer> {
|
|
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8 as BufferSource, { name: 'Ed25519' }, true, ['sign'])
|
|
// WebCrypto can't derive the public key directly; the JWK export of a private key includes it (`x`).
|
|
const jwk = await crypto.subtle.exportKey('jwk', privateKey)
|
|
const publicKey = Uint8Array.from(atob(jwk.x!.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0))
|
|
return new Ed25519Signer(privateKey, Hex.encode(publicKey))
|
|
}
|
|
|
|
async exportPrivateKey(): Promise<Uint8Array> {
|
|
return new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.privateKey))
|
|
}
|
|
|
|
async sign(payload: Uint8Array): Promise<Uint8Array> {
|
|
return new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, this.privateKey, payload as BufferSource))
|
|
}
|
|
}
|
|
|
|
/** Ed25519 verifier using WebCrypto. */
|
|
export class Ed25519Verifier implements SignatureVerifier {
|
|
readonly algorithm = 'ed25519'
|
|
private readonly keys = new Map<string, Promise<CryptoKey>>()
|
|
|
|
async verify(publicKey: string, payload: Uint8Array, signature: Uint8Array): Promise<boolean> {
|
|
let key = this.keys.get(publicKey)
|
|
if (!key) {
|
|
key = crypto.subtle.importKey('raw', Hex.decode(publicKey) as BufferSource, { name: 'Ed25519' }, false, ['verify'])
|
|
this.keys.set(publicKey, key)
|
|
if (this.keys.size > 10_000) this.keys.delete(this.keys.keys().next().value!)
|
|
}
|
|
return crypto.subtle.verify({ name: 'Ed25519' }, await key, signature as BufferSource, payload as BufferSource)
|
|
}
|
|
}
|