134 lines
4.2 KiB
TypeScript
134 lines
4.2 KiB
TypeScript
|
|
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;
|
||
|
|
}
|