Reference source · 0.1.0-dev.11
identity.ts
src/identity.ts · Download original file
import { createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
import type { KeyObject } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { b64, unb64, jsonBytes, parseObject, exact, id, integer, fingerprint, newId, requireThat } from './core.ts';
export type PublicIdentity = { subject: string; signingKey: string; encryptionKey: string; signingKid: string; encryptionKid: string };
export type Identity = { public: PublicIdentity; signingPrivate: KeyObject; encryptionPrivate: KeyObject };
export type Credential = { payload: string; signature: string };
export type CredentialBody = PublicIdentity & { issuer: string; serial: string; kind: 'host' | 'application' | 'person'; roles: string[]; nbf: number; exp: number };
export type Authority = { issuer: string; publicKey: string; privateKey: KeyObject };
export type AuthorityPolicy = { publicKey: string; kinds: CredentialBody['kind'][]; subjectPrefixes: string[]; roles: Partial<Record<CredentialBody['kind'], string[]>>; maximumLifetime: number; maximumStatusAge: number };
export type SignedStatus = { payload: string; signature: string };
type StatusBody = { issuer: string; sequence: number; issued_at: number; next_update: number; revoked_serials: string[]; revoked_signing_kids: string[]; revoked_encryption_kids: string[] };
export function signStatus(authority: Authority, sequence: number, now: number, revoked: Partial<Pick<StatusBody, 'revoked_serials' | 'revoked_signing_kids' | 'revoked_encryption_kids'>> = {}, lifetime = 600): SignedStatus {
const payload = b64(jsonBytes({ issuer: authority.issuer, sequence, issued_at: now, next_update: now + lifetime, revoked_serials: [], revoked_signing_kids: [], revoked_encryption_kids: [], ...revoked }));
return { payload, signature: b64(sign(null, Buffer.from(`OPRP-STATUS-0.1.${payload}`), authority.privateKey)) };
}
// DER SubjectPublicKeyInfo algorithm identifiers from the native OpenSSL exports.
const prefixes = {
signing: Buffer.from('308207b2300b0609608648016503040312038207a100', 'hex'),
encryption: Buffer.from('308204b2300b0609608648016503040402038204a100', 'hex'),
};
export function publicKey(raw: string, purpose: 'signing' | 'encryption'): KeyObject {
const size = purpose === 'signing' ? 1952 : 1184;
return createPublicKey({ format: 'der', type: 'spki', key: Buffer.concat([prefixes[purpose], unb64(raw, size)]) });
}
export function rawPublic(key: KeyObject, purpose: 'signing' | 'encryption'): Buffer {
const der = key.export({ format: 'der', type: 'spki' });
requireThat(der.subarray(0, prefixes[purpose].length).equals(prefixes[purpose]), 'unsupported_key');
return der.subarray(prefixes[purpose].length);
}
export function makeIdentity(subject: string): Identity {
id(subject);
const signing = generateKeyPairSync('ml-dsa-65');
const encryption = generateKeyPairSync('ml-kem-768');
const sp = rawPublic(signing.publicKey, 'signing');
const ep = rawPublic(encryption.publicKey, 'encryption');
return { public: { subject, signingKey: b64(sp), encryptionKey: b64(ep), signingKid: fingerprint(sp), encryptionKid: fingerprint(ep) },
signingPrivate: signing.privateKey, encryptionPrivate: encryption.privateKey };
}
export function makeAuthority(issuer: string): Authority {
id(issuer);
const keys = generateKeyPairSync('ml-dsa-65');
return { issuer, publicKey: b64(rawPublic(keys.publicKey, 'signing')), privateKey: keys.privateKey };
}
export function signCredential(authority: Authority, identity: PublicIdentity, kind: CredentialBody['kind'], now: number, roles: string[] = []): Credential {
const body: CredentialBody = { ...identity, issuer: authority.issuer, serial: newId(), kind, roles, nbf: now, exp: now + 86400 };
const payload = b64(jsonBytes(body));
return { payload, signature: b64(sign(null, Buffer.from(`OPRP-CREDENTIAL-0.1.${payload}`), authority.privateKey)) };
}
export class TrustStore {
roots: Map<string, string>;
policies: Record<string, AuthorityPolicy>; db: DatabaseSync;
constructor(policies: Record<string, AuthorityPolicy>, path = ':memory:') {
this.policies = structuredClone(policies); this.roots = new Map(Object.entries(policies).map(([issuer, policy]) => [issuer, policy.publicKey]));
for (const policy of Object.values(policies)) {
requireThat(policy.kinds.length > 0 && policy.subjectPrefixes.length > 0 && policy.subjectPrefixes.every(prefix => prefix.endsWith(':')), 'invalid_authority_policy');
integer(policy.maximumLifetime); integer(policy.maximumStatusAge); requireThat(policy.maximumLifetime > 0 && policy.maximumStatusAge > 0);
publicKey(policy.publicKey, 'signing');
}
this.db = new DatabaseSync(path);
this.db.exec(`CREATE TABLE IF NOT EXISTS issuer_status (issuer TEXT PRIMARY KEY, sequence INTEGER NOT NULL, issued_at INTEGER NOT NULL, payload TEXT NOT NULL, document TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS revoked_credentials (issuer TEXT NOT NULL, kind TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(issuer,kind,value));
CREATE TABLE IF NOT EXISTS disabled_issuers (issuer TEXT PRIMARY KEY);`);
}
statusBody(document: SignedStatus, now: number): StatusBody {
exact(document, ['payload', 'signature']); requireThat(typeof document.payload === 'string' && document.payload.length <= 100000, 'invalid_status');
const body = parseObject(unb64(document.payload)) as StatusBody;
exact(body, ['issuer', 'sequence', 'issued_at', 'next_update', 'revoked_serials', 'revoked_signing_kids', 'revoked_encryption_kids']); id(body.issuer);
const policy = this.policies[body.issuer]; requireThat(policy, 'untrusted_issuer');
[body.sequence, body.issued_at, body.next_update].forEach(integer);
requireThat(body.issued_at <= now && now < body.next_update && body.next_update - body.issued_at <= policy.maximumStatusAge, 'stale_issuer_status');
for (const list of [body.revoked_serials, body.revoked_signing_kids, body.revoked_encryption_kids]) {
requireThat(Array.isArray(list) && list.length <= 1000 && new Set(list).size === list.length, 'invalid_status'); list.forEach(id);
}
requireThat(verify(null, Buffer.from(`OPRP-STATUS-0.1.${document.payload}`), publicKey(policy.publicKey, 'signing'), unb64(document.signature, 3309)), 'invalid_signature');
return body;
}
ingestStatus(document: SignedStatus, now: number): void {
const body = this.statusBody(document, now);
this.db.exec('BEGIN IMMEDIATE');
try {
const prior = this.db.prepare('SELECT * FROM issuer_status WHERE issuer=?').get(body.issuer) as any;
requireThat(!prior || (body.sequence > prior.sequence && body.issued_at >= prior.issued_at) || (body.sequence === prior.sequence && document.payload === prior.payload), 'status_rollback_or_equivocation');
this.db.prepare('INSERT INTO issuer_status VALUES (?, ?, ?, ?, ?) ON CONFLICT(issuer) DO UPDATE SET sequence=excluded.sequence, issued_at=excluded.issued_at, payload=excluded.payload, document=excluded.document').run(body.issuer, body.sequence, body.issued_at, document.payload, JSON.stringify(document));
for (const [kind, values] of Object.entries({ serial: body.revoked_serials, signing: body.revoked_signing_kids, encryption: body.revoked_encryption_kids })) for (const value of values) this.revoke(body.issuer, kind as 'serial' | 'signing' | 'encryption', value);
this.db.exec('COMMIT');
} catch (e) { this.db.exec('ROLLBACK'); throw e; }
}
revoke(issuer: string, kind: 'serial' | 'signing' | 'encryption', value: string): void {
requireThat(this.policies[issuer], 'untrusted_issuer'); id(value);
this.db.prepare('INSERT OR IGNORE INTO revoked_credentials VALUES (?, ?, ?)').run(issuer, kind, value);
}
disableIssuer(issuer: string): void { requireThat(this.policies[issuer], 'untrusted_issuer'); this.db.prepare('INSERT OR IGNORE INTO disabled_issuers VALUES (?)').run(issuer); }
statusEvidence(issuer: string): SignedStatus {
const row = this.db.prepare('SELECT document FROM issuer_status WHERE issuer=?').get(issuer) as any; requireThat(row, 'missing_issuer_status'); return JSON.parse(row.document);
}
validate(credential: Credential, now: number, expectedSubject?: string, expectedKind?: CredentialBody['kind'], historicalStatus?: SignedStatus): CredentialBody {
exact(credential, ['payload', 'signature']);
requireThat(credential.payload.length <= 12000);
const body = parseObject(unb64(credential.payload)) as CredentialBody;
exact(body, ['subject', 'signingKey', 'encryptionKey', 'signingKid', 'encryptionKid', 'issuer', 'serial', 'kind', 'roles', 'nbf', 'exp']);
[body.subject, body.issuer, body.serial].forEach(id);
integer(body.nbf); integer(body.exp);
requireThat(['host', 'application', 'person'].includes(body.kind));
requireThat(Array.isArray(body.roles) && body.roles.length <= 16 && body.roles.every(x => typeof x === 'string' && /^[a-z-]{1,40}$/.test(x)));
const root = this.roots.get(body.issuer);
requireThat(root, 'untrusted_issuer');
requireThat(!this.db.prepare('SELECT issuer FROM disabled_issuers WHERE issuer=?').get(body.issuer), 'disabled_issuer');
const policy = this.policies[body.issuer];
requireThat(verify(null, Buffer.from(`OPRP-CREDENTIAL-0.1.${credential.payload}`), publicKey(root, 'signing'), unb64(credential.signature, 3309)), 'invalid_signature');
requireThat(policy.kinds.includes(body.kind) && policy.subjectPrefixes.some(prefix => body.subject.startsWith(prefix)) && body.roles.every(role => (policy.roles[body.kind] ?? []).includes(role)) && body.exp > body.nbf && body.exp - body.nbf <= policy.maximumLifetime, 'authority_constraint_violation');
requireThat(body.nbf <= now && now < body.exp, 'expired_credential');
const status = this.statusBody(historicalStatus ?? this.statusEvidence(body.issuer), now);
requireThat(status.issuer === body.issuer, 'wrong_status_issuer');
for (const [kind, value, revoked] of [['serial', body.serial, status.revoked_serials], ['signing', body.signingKid, status.revoked_signing_kids], ['encryption', body.encryptionKid, status.revoked_encryption_kids]] as const) {
requireThat(!revoked.includes(value) && !this.db.prepare('SELECT value FROM revoked_credentials WHERE issuer=? AND kind=? AND value=?').get(body.issuer, kind, value), 'revoked_credential');
}
requireThat(!expectedSubject || body.subject === expectedSubject, 'wrong_identity');
requireThat(!expectedKind || body.kind === expectedKind, 'wrong_identity_kind');
requireThat(fingerprint(unb64(body.signingKey, 1952)) === body.signingKid);
requireThat(fingerprint(unb64(body.encryptionKey, 1184)) === body.encryptionKid);
return body;
}
close(): void { this.db.close(); }
}