import type { CryptoProvider } from '@shade/core'; import { fromBase64, UnauthorizedError, ValidationError, ReplayError } from '@shade/core'; /** * Self-authenticated prekey server. * * Each write request must include a signature over a canonical * representation of the body (excluding the signature field itself) * using the identity's Ed25519 signing key. * * On register: the signing key is extracted from the body itself * (TOFU — first registration establishes the identity). * * On replenish/delete: the signing key is looked up from the stored * identity record for the address. */ /** Maximum age of a signed request, in milliseconds */ const MAX_SIGNATURE_AGE_MS = 5 * 60 * 1000; // 5 minutes /** Maximum clock skew tolerated (future timestamps) */ const MAX_FUTURE_SKEW_MS = 1 * 60 * 1000; // 1 minute interface SignedPayload { signedAt: number; signature: string; // base64 [key: string]: unknown; } /** * Canonicalize a payload for signing by omitting the `signature` field * and using deterministic JSON key ordering. * * Both signer and verifier must produce identical bytes. */ export function canonicalizePayload(payload: SignedPayload): Uint8Array { const { signature, ...rest } = payload; // Sort keys to be deterministic const sorted = Object.keys(rest) .sort() .reduce>((acc, key) => { acc[key] = (rest as Record)[key]; return acc; }, {}); return new TextEncoder().encode(JSON.stringify(sorted)); } /** * Sign a payload with an Ed25519 signing private key. * Returns the payload with `signature` and `signedAt` set. */ export async function signPayload>( crypto: CryptoProvider, signingPrivateKey: Uint8Array, payload: T, ): Promise { const signedAt = Date.now(); const bytes = canonicalizePayload({ ...payload, signedAt, signature: '' }); const sig = await crypto.sign(signingPrivateKey, bytes); return { ...payload, signedAt, signature: Buffer.from(sig).toString('base64'), }; } /** * Verify a signed payload against a given signing public key. * * Throws: * - ValidationError if signedAt or signature fields are missing/malformed * - ReplayError if signedAt is outside the allowed window * - UnauthorizedError if the signature does not verify */ export async function verifyPayload( crypto: CryptoProvider, signingPublicKey: Uint8Array, payload: unknown, ): Promise { if (!payload || typeof payload !== 'object') { throw new ValidationError('Payload must be an object'); } const p = payload as SignedPayload; if (typeof p.signedAt !== 'number') { throw new ValidationError('Missing or invalid signedAt', 'signedAt'); } if (typeof p.signature !== 'string') { throw new ValidationError('Missing or invalid signature', 'signature'); } const now = Date.now(); const age = now - p.signedAt; if (age > MAX_SIGNATURE_AGE_MS) { throw new ReplayError(`Signature too old: ${age}ms`); } if (age < -MAX_FUTURE_SKEW_MS) { throw new ReplayError(`Signature in the future: ${-age}ms ahead`); } let sigBytes: Uint8Array; try { sigBytes = fromBase64(p.signature); } catch { throw new ValidationError('Signature is not valid base64', 'signature'); } const canonical = canonicalizePayload(p); const valid = await crypto.verify(signingPublicKey, canonical, sigBytes); if (!valid) { throw new UnauthorizedError('Invalid signature'); } } // ─── Address validation ───────────────────────────────────── const ADDRESS_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9:_\-.]{0,255}$/; /** * Validate a peer address string. * Allows alphanumeric, :, _, -, . with max 256 chars and must start with alphanumeric. * Rejects path traversal, unicode tricks, and empty strings. */ export function validateAddress(address: unknown): string { if (typeof address !== 'string') { throw new ValidationError('Address must be a string', 'address'); } const normalized = address.normalize('NFKC'); if (!ADDRESS_REGEX.test(normalized)) { throw new ValidationError('Invalid address format', 'address'); } return normalized; }