Reference source · 0.1.0-dev.11
discovery.ts
src/discovery.ts · Download original file
import { sign, verify } from 'node:crypto';
import { request as httpsRequest } from 'node:https';
import type { Authority, Credential, CredentialBody } from './identity.ts';
import { publicKey, TrustStore } from './identity.ts';
import { b64, unb64, jsonBytes, parseObject, exact, integer, id, requireThat, ProtocolError } from './core.ts';
export const DISCOVERY_REL = 'urn:oprp:experimental:0.1:host';
export type Delegation = { payload: string; signature: string };
export type DiscoveryBody = { v: '0.1'; issuer: string; address: string; record_id: string; endpoint: string; host: string; credential: Credential; issued_at: number; expires_at: number; generation: number };
export type DirectoryRoot = { publicKey: string; domains: string[] };
export type LocatedHost = DiscoveryBody & { identity: CredentialBody; delegation: Delegation };
// Deliberately narrow initial address grammar. These are identifiers, not email delivery addresses.
export function patientAddress(address: string): { address: string; domain: string } {
requireThat(typeof address === 'string' && address.length <= 254, 'invalid_address');
const match = /^(?:acct:)?([a-zA-Z0-9._~-]{1,64})@([a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?)$/.exec(address);
requireThat(match && !match[2].includes('..') && match[2].split('.').every(label => label.length <= 63 && !label.startsWith('-') && !label.endsWith('-')), 'invalid_address');
return { address: `acct:${match[1]}@${match[2]}`, domain: match[2] };
}
function secureURL(endpoint: string): URL {
const url = new URL(endpoint);
requireThat(url.protocol === 'https:' && !url.username && !url.password && !url.hash, 'invalid_endpoint');
return url;
}
export function delegate(authority: Authority, body: Omit<DiscoveryBody, 'v' | 'issuer'>): Delegation {
const payload = b64(jsonBytes({ v: '0.1', issuer: authority.issuer, ...body }));
return { payload, signature: b64(sign(null, Buffer.from(`OPRP-DISCOVERY-0.1.${payload}`), authority.privateKey)) };
}
export function webfinger(address: string, delegation: Delegation): Record<string, any> {
return { subject: patientAddress(address).address, links: [{ rel: DISCOVERY_REL, properties: { delegation } }] };
}
export class DirectoryTrust {
roots: Map<string, DirectoryRoot>; credentials: TrustStore;
constructor(roots: Record<string, DirectoryRoot>, credentials: TrustStore) { this.roots = new Map(Object.entries(roots)); this.credentials = credentials; }
validate(document: Record<string, any>, requestedAddress: string, now: number, minimumGeneration = 1): LocatedHost {
const requested = patientAddress(requestedAddress);
requireThat(document.subject === requested.address && Array.isArray(document.links), 'wrong_discovery_address');
const links = document.links.filter((link: any) => link.rel === DISCOVERY_REL);
requireThat(links.length === 1, 'ambiguous_discovery');
const delegation = links[0].properties?.delegation as Delegation;
exact(delegation, ['payload', 'signature']); requireThat(typeof delegation.payload === 'string' && delegation.payload.length <= 20000);
const body = parseObject(unb64(delegation.payload)) as DiscoveryBody;
exact(body, ['v','issuer','address','record_id','endpoint','host','credential','issued_at','expires_at','generation']);
const root = this.roots.get(body.issuer);
requireThat(root && root.domains.includes(requested.domain), 'untrusted_address_authority');
requireThat(verify(null, Buffer.from(`OPRP-DISCOVERY-0.1.${delegation.payload}`), publicKey(root.publicKey, 'signing'), unb64(delegation.signature, 3309)), 'invalid_signature');
[body.issued_at, body.expires_at, body.generation].forEach(integer); id(body.record_id); id(body.host);
requireThat(body.v === '0.1' && body.address === requested.address, 'wrong_discovery_address');
requireThat(body.issued_at <= now + 30 && now < body.expires_at && body.expires_at > body.issued_at && body.expires_at - body.issued_at <= 600, 'expired_discovery');
requireThat(body.generation >= minimumGeneration, 'stale_discovery_generation'); secureURL(body.endpoint);
const identity = this.credentials.validate(body.credential, now, body.host, 'host');
return { ...body, identity, delegation: structuredClone(delegation) };
}
}
export async function discover(address: string, trust: DirectoryTrust, now: number, options: { ca?: string; origin?: string; minimumGeneration?: number } = {}): Promise<LocatedHost> {
const parsed = patientAddress(address);
// origin is an explicit integration/test override; production defaults to the address's DNS domain.
const url = new URL('/.well-known/webfinger', options.origin ?? `https://${parsed.domain}`);
secureURL(url.href); url.searchParams.set('resource', parsed.address);
const document = await new Promise<Record<string, any>>((resolve, reject) => {
const request = httpsRequest(url, { method: 'GET', ca: options.ca, timeout: 10000, headers: { accept: 'application/jrd+json' } }, response => {
const chunks: Buffer[] = []; let size = 0;
response.on('data', (chunk: Buffer) => { size += chunk.length; if (size > 40000) { response.destroy(new ProtocolError('discovery_too_large')); return; } chunks.push(chunk); });
response.on('error', reject);
response.on('end', () => { try { requireThat(response.statusCode === 200 && response.headers['content-type']?.split(';')[0] === 'application/jrd+json', 'discovery_transport_error'); resolve(parseObject(Buffer.concat(chunks), false)); } catch (e) { reject(e); } });
});
request.on('timeout', () => request.destroy(new ProtocolError('transport_timeout'))); request.on('error', reject); request.end();
});
return trust.validate(document, parsed.address, now, options.minimumGeneration);
}