/** * T2 — synchronous AEAD primitives over @noble/ciphers (AES-256-GCM + XChaCha20-Poly1305). * * SYNCHRONOUS so it satisfies the frozen §4.4 `sealFrame`/`openFrame` synchronous signatures. * @noble/ciphers is audited, isomorphic, and provides XChaCha (which WebCrypto lacks). Noble's * one-shot AEAD returns `ciphertext‖tag`; §4.4 `E2EEnvelope` keeps them split, so we slice the * fixed 16-byte tag off the tail on seal and re-join on open. */ import { gcm } from '@noble/ciphers/aes' import { xchacha20poly1305 } from '@noble/ciphers/chacha' import type { AeadAlg, AeadKey } from 'relay-contracts' import { NONCE_BYTES } from 'relay-contracts' import { AeadOpenError, E2EError } from './errors.js' import { unwrapAeadKey, wrapAeadKey } from './aead-key.js' const TAG_BYTES = 16 as const /** Nonce width for an AEAD alg (12 GCM · 24 XChaCha, §4.4). */ export function nonceLength(alg: AeadAlg): 12 | 24 { const width = NONCE_BYTES[alg] return width as 12 | 24 } /** Fixed AEAD tag length (16 bytes for both algs). */ export function tagLength(_alg: AeadAlg): 16 { return TAG_BYTES } /** Construct the frozen opaque `AeadKey` from 32 raw bytes (P4-local constructor, INDEX §2.1). */ export function importAeadKey(raw: Uint8Array, alg: AeadAlg, aadLabel = ''): AeadKey { return wrapAeadKey(raw, alg, aadLabel) } function cipherFor(alg: AeadAlg, raw: Uint8Array, nonce: Uint8Array, aad: Uint8Array) { if (nonce.length !== nonceLength(alg)) { throw new E2EError( 'E2E_NONCE_LENGTH', `nonce length ${nonce.length} != required ${nonceLength(alg)} for ${alg}`, ) } return alg === 'aes-256-gcm' ? gcm(raw, nonce, aad) : xchacha20poly1305(raw, nonce, aad) } /** Seal plaintext; returns split ciphertext + 16-byte tag (§4.4 E2EEnvelope). */ export function aeadSeal( key: AeadKey, nonce: Uint8Array, plaintext: Uint8Array, aad: Uint8Array, ): { ciphertext: Uint8Array; tag: Uint8Array } { const { raw, alg } = unwrapAeadKey(key) const sealed = cipherFor(alg, raw, nonce, aad).encrypt(plaintext) const cut = sealed.length - TAG_BYTES return { ciphertext: sealed.slice(0, cut), tag: sealed.slice(cut) } } /** Open ciphertext+tag; throws {@link AeadOpenError} on any tag/aad/key/nonce mismatch. */ export function aeadOpen( key: AeadKey, nonce: Uint8Array, ciphertext: Uint8Array, tag: Uint8Array, aad: Uint8Array, ): Uint8Array { if (tag.length !== TAG_BYTES) { throw new AeadOpenError(`tag length ${tag.length} != ${TAG_BYTES}`) } const { raw, alg } = unwrapAeadKey(key) const joined = new Uint8Array(ciphertext.length + tag.length) joined.set(ciphertext, 0) joined.set(tag, ciphertext.length) try { return cipherFor(alg, raw, nonce, aad).decrypt(joined) } catch { throw new AeadOpenError() } }