Files
Shade/packages/shade-server/src/auth.ts

134 lines
4.2 KiB
TypeScript
Raw Normal View History

feat(hardening): M-Hard 1-5 — crypto, auth, rate limit, fingerprints, rotation M-Hard 1: Cryptographic Hardening - constantTimeEqual, zeroize, randomUint32 on CryptoProvider - Fix Math.random() → crypto.randomUint32() for registrationId - Zero message keys and chain keys after use in ratchet.ts - Constant-time trust comparison in MemoryStorage + SQLiteStorage - Timing variance test catches early-exit regressions M-Hard 2: Self-Authenticated Prekey Server - Ed25519 signature verification on all write routes - signPayload/verifyPayload with canonical JSON, ±5 min replay window - Address validation (NFKC, alphanumeric + :_-.) - Global ShadeError → HTTP status mapping - ShadeFetchTransport signs requests when signingPrivateKey provided - Anonymous bundle fetches still allowed (read-only) M-Hard 3: Rate Limiting + DoS Protection - Token bucket rate limiter with pluggable store - Per-route limits: register 5/h/IP, fetch 60/min/IP, replenish 10/min/id - 64KB body size limit on POST - Retry-After header on 429 responses M-Hard 4: Auto-replenish + Fingerprints + Session Reset - Safety numbers (12 groups × 5 digits, Signal-style) - ensurePreKeyStock, resetSession, acceptIdentityChange - verifyRemoteIdentity for out-of-band comparison M-Hard 5: Identity Rotation with Grace Period - rotateIdentity archives old identity, generates fresh signed prekey - RetiredIdentity storage with addRetired/getRetired/pruneRetired - 7-day default grace period for decrypting old sessions - pruneExpiredIdentities for cleanup M-Hard 8: Error Hierarchy - New error types: Network, Storage, Validation, Timeout, RateLimit, Configuration, Unauthorized, Replay, IdentityRotation - All errors have stable SHADE_* codes - errorToHttpStatus for consistent HTTP mapping - toJSON() for network serialization 188 tests passing, zero failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:45:34 +02:00
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<Record<string, unknown>>((acc, key) => {
acc[key] = (rest as Record<string, unknown>)[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<T extends Record<string, unknown>>(
crypto: CryptoProvider,
signingPrivateKey: Uint8Array,
payload: T,
): Promise<T & { signedAt: number; signature: string }> {
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<void> {
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;
}