Skip to content

Reference source · 0.1.0-dev.11

envelope.ts

src/envelope.ts · Download original file

import { createCipheriv, createDecipheriv, createHash, encapsulate, decapsulate, hkdfSync, randomBytes, sign, verify } from 'node:crypto';
import type { Identity, Credential, CredentialBody, PublicIdentity } from './identity.ts';
import { publicKey, TrustStore } from './identity.ts';
import { b64, unb64, jsonBytes, parseObject, exact, id, integer, frame, newId, requireThat, ProtocolError } from './core.ts';

export const SUITE = 'OPRP-0.1-MLKEM768-MLDSA65-HKDFSHA512-A256GCM';
export type Header = { v: '0.1'; suite: string; sender: string; recipient: string; signingKid: string; encryptionKid: string; messageId: string; issuedAt: number; expiresAt: number; inReplyTo: string | null };
export type Envelope = { protected: string; credential: Credential; kem: string; nonce: string; ciphertext: string; tag: string; signature: string };
export type Opened = { header: Header; sender: CredentialBody; body: Record<string, any> };
const DOMAIN = Buffer.from('OPRP-ENVELOPE-0.1');
function aad(encoded: string, kem: Buffer, nonce: Buffer): Buffer {
  return frame(DOMAIN, Buffer.from(encoded), kem, nonce);
}
function derive(secret: Buffer, associated: Buffer): Buffer {
  return Buffer.from(hkdfSync('sha512', secret, createHash('sha512').update(associated).digest(), Buffer.from('OPRP-0.1/payload-key'), 32));
}
export function seal(body: Record<string, any>, sender: Identity, credential: Credential, recipient: PublicIdentity, now: number, inReplyTo: string | null = null): Envelope {
  const header: Header = { v: '0.1', suite: SUITE, sender: sender.public.subject, recipient: recipient.subject,
    signingKid: sender.public.signingKid, encryptionKid: recipient.encryptionKid, messageId: newId(), issuedAt: now, expiresAt: now + 120, inReplyTo };
  const protectedValue = b64(jsonBytes(header));
  const clear = jsonBytes(body);
  requireThat(clear.length <= 1024 * 1024, 'message_too_large');
  const { sharedKey, ciphertext: kem } = encapsulate(publicKey(recipient.encryptionKey, 'encryption'));
  const nonce = randomBytes(12);
  const associated = aad(protectedValue, kem, nonce);
  const key = derive(sharedKey, associated);
  try {
    const cipher = createCipheriv('aes-256-gcm', key, nonce); cipher.setAAD(associated);
    const ciphertext = Buffer.concat([cipher.update(clear), cipher.final()]);
    const tag = cipher.getAuthTag();
    const signature = sign(null, frame(associated, ciphertext, tag), sender.signingPrivate);
    return { protected: protectedValue, credential, kem: b64(kem), nonce: b64(nonce), ciphertext: b64(ciphertext), tag: b64(tag), signature: b64(signature) };
  } finally { sharedKey.fill(0); key.fill(0); clear.fill(0); }
}
export function open(envelope: Envelope, recipient: Identity, trust: TrustStore, now: number, expectedSender?: string, expectedReply?: string | null): Opened {
  exact(envelope, ['protected', 'credential', 'kem', 'nonce', 'ciphertext', 'tag', 'signature']);
  requireThat(typeof envelope.protected === 'string' && envelope.protected.length <= 4000);
  requireThat(typeof envelope.ciphertext === 'string' && envelope.ciphertext.length <= 1400000, 'message_too_large');
  const header = parseObject(unb64(envelope.protected)) as Header;
  exact(header, ['v', 'suite', 'sender', 'recipient', 'signingKid', 'encryptionKid', 'messageId', 'issuedAt', 'expiresAt', 'inReplyTo']);
  requireThat(header.v === '0.1' && header.suite === SUITE, 'unsupported_suite');
  [header.sender, header.recipient, header.messageId].forEach(id);
  if (header.inReplyTo !== null) id(header.inReplyTo);
  integer(header.issuedAt); integer(header.expiresAt);
  requireThat(header.issuedAt <= now + 30 && now < header.expiresAt && header.expiresAt > header.issuedAt && header.expiresAt - header.issuedAt <= 120, 'expired_message');
  requireThat(header.recipient === recipient.public.subject && header.encryptionKid === recipient.public.encryptionKid, 'wrong_recipient');
  requireThat(expectedReply === undefined || header.inReplyTo === expectedReply, 'wrong_reply');
  const sender = trust.validate(envelope.credential, now, header.sender);
  requireThat(sender.signingKid === header.signingKid && (!expectedSender || sender.subject === expectedSender), 'wrong_sender');
  const kem = unb64(envelope.kem, 1088), nonce = unb64(envelope.nonce, 12), ciphertext = unb64(envelope.ciphertext), tag = unb64(envelope.tag, 16);
  const associated = aad(envelope.protected, kem, nonce);
  requireThat(verify(null, frame(associated, ciphertext, tag), publicKey(sender.signingKey, 'signing'), unb64(envelope.signature, 3309)), 'invalid_signature');
  const secret = decapsulate(recipient.encryptionPrivate, kem);
  const key = derive(secret, associated);
  try {
    const decipher = createDecipheriv('aes-256-gcm', key, nonce); decipher.setAAD(associated); decipher.setAuthTag(tag);
    return { header, sender, body: parseObject(Buffer.concat([decipher.update(ciphertext), decipher.final()])) };
  } catch { throw new ProtocolError('invalid_ciphertext'); }
  finally { secret.fill(0); key.fill(0); }
}