Authenticated, scoped sync: signed requests and per-path subscriptions
- 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
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user