Skip to content

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(); }
}