{"version":3,"sources":["../src/encryption.ts"],"sourcesContent":["// Private Pool V2 — encrypted note format (ADR-003).\n//\n//   ChaCha20-Poly1305 AEAD\n//   X25519 ephemeral key agreement → HKDF-SHA256(shared, salt, info=commitment)\n//   nonce = blake2b256(eph_pk || commitment)[:12]\n//   AAD   = commitment (32 bytes)\n//\n// Plaintext layout (memo-less, 80 bytes):\n//   0..8    value     (u64 LE, micro-USDC)\n//   8..40   blinding  (32 bytes BE)\n//   40..72  owner_pk  (32 bytes BE)\n//   72      memo flag (0x00)\n//   73..80  zero pad\n//\n// Plaintext layout (with memo, 112 bytes): same as above but\n//   72      memo flag (0x01)\n//   80..112 memo (32 bytes)\n//\n// On-chain `EncryptedNoteBlobAccount`:\n//   - ephemeral_pk: 32 bytes (separate field)\n//   - ciphertext:   96 or 128 bytes (Vec<u8>) — plaintext + 16-byte Poly1305 tag\n//\n// Conformance vectors: see `frontend/src/__tests__/private-pool-encryption.test.ts`.\n\nimport { chacha20poly1305 } from '@noble/ciphers/chacha';\nimport { x25519 } from '@noble/curves/ed25519';\nimport { hkdf } from '@noble/hashes/hkdf';\nimport { blake2b } from '@noble/hashes/blake2b';\nimport { sha256 } from '@noble/hashes/sha2';\n\nconst HKDF_SALT = new TextEncoder().encode('RelAI PrivatePool v2 note key');\nconst NONCE_LEN = 12;\nconst TAG_LEN = 16;\n\n// Plaintext layouts (legacy ADR-003 only — stealth was withdrawn,\n// see ADR-008 postmortem):\n//   80B  no memo:   value(8) + blinding(32) + owner_pk(32) + flag=0(1) + 7B pad\n//   112B with memo: above + 32B memo at offset 80, flag=1\nconst PLAINTEXT_NO_MEMO = 80;\nconst PLAINTEXT_WITH_MEMO = 112;\nconst CIPHERTEXT_NO_MEMO = PLAINTEXT_NO_MEMO + TAG_LEN; // 96\nconst CIPHERTEXT_WITH_MEMO = PLAINTEXT_WITH_MEMO + TAG_LEN; // 128\n\nconst EPHEMERAL_PK_LEN = 32;\nconst COMMITMENT_LEN = 32;\n\nexport const PRIVATE_POOL_NOTE_BYTES = {\n  PLAINTEXT_NO_MEMO,\n  PLAINTEXT_WITH_MEMO,\n  CIPHERTEXT_NO_MEMO,\n  CIPHERTEXT_WITH_MEMO,\n  EPHEMERAL_PK_LEN,\n  COMMITMENT_LEN,\n  NONCE_LEN,\n  TAG_LEN,\n} as const;\n\nexport type NoteLayout = 'legacy';\n\nexport type DecryptedNote = {\n  value: bigint;\n  blinding: Uint8Array; // 32 bytes BE\n  ownerPk: Uint8Array; // 32 bytes BE — recipient's master spending_pk\n  memo: Uint8Array | null; // 32 bytes when present, null otherwise\n  /// Always `null` in V1 (stealth withdrawn — see ADR-008 postmortem).\n  /// Field kept on the type so callers don't have to branch on a\n  /// missing property; the encryption layer enforces null.\n  stealthNonce: null;\n  /// Always `'legacy'` in V1.\n  layout: NoteLayout;\n};\n\n// ── Plaintext (de)serialisation ──────────────────────────────────────────\n\nfunction ensureLen(label: string, bytes: Uint8Array, expected: number): void {\n  if (bytes.length !== expected) {\n    throw new Error(`${label}: expected ${expected} bytes, got ${bytes.length}`);\n  }\n}\n\n/// Pack a `DecryptedNote` to its on-chain plaintext bytes.\n///\n/// Layout selection (auto):\n///   - `stealthNonce: null, memo: null` → 80B legacy (ADR-003 v1)\n///   - `memo: null` → 80B (no memo)\n///   - `memo: Uint8Array` → 112B (with memo)\nexport function serializeNotePlaintext(note: DecryptedNote): Uint8Array {\n  ensureLen('blinding', note.blinding, 32);\n  ensureLen('ownerPk', note.ownerPk, 32);\n  if (note.memo) ensureLen('memo', note.memo, 32);\n\n  const isWithMemo = !!note.memo;\n  const len = isWithMemo ? PLAINTEXT_WITH_MEMO : PLAINTEXT_NO_MEMO;\n  const out = new Uint8Array(len);\n\n  const view = new DataView(out.buffer, out.byteOffset, 8);\n  view.setBigUint64(0, note.value, true);\n  out.set(note.blinding, 8);\n  out.set(note.ownerPk, 40);\n  out[72] = isWithMemo ? 0x01 : 0x00;\n  if (isWithMemo) out.set(note.memo!, 80);\n  return out;\n}\n\n/// Inverse of `serializeNotePlaintext`. Throws on malformed inputs.\nexport function parseNotePlaintext(plaintext: Uint8Array): DecryptedNote {\n  const view = new DataView(plaintext.buffer, plaintext.byteOffset, plaintext.byteLength);\n  const flag = plaintext.length >= 73 ? plaintext[72] : 0xff;\n\n  if (plaintext.length === PLAINTEXT_NO_MEMO && flag === 0x00) {\n    return {\n      value: view.getBigUint64(0, true),\n      blinding: plaintext.slice(8, 40),\n      ownerPk: plaintext.slice(40, 72),\n      memo: null,\n      stealthNonce: null,\n      layout: 'legacy',\n    };\n  }\n  if (plaintext.length === PLAINTEXT_WITH_MEMO && flag === 0x01) {\n    return {\n      value: view.getBigUint64(0, true),\n      blinding: plaintext.slice(8, 40),\n      ownerPk: plaintext.slice(40, 72),\n      memo: plaintext.slice(80, 112),\n      stealthNonce: null,\n      layout: 'legacy',\n    };\n  }\n  throw new Error(\n    `note plaintext: unrecognized layout (length ${plaintext.length}, memo flag ${flag.toString(16)})`,\n  );\n}\n\n// ── Key agreement ────────────────────────────────────────────────────────\n\n/// HKDF-SHA256 with ADR-003 salt + commitment-as-info → 32-byte key.\nfunction deriveSymmetricKey(sharedSecret: Uint8Array, commitment: Uint8Array): Uint8Array {\n  return hkdf(sha256, sharedSecret, HKDF_SALT, commitment, 32);\n}\n\n/// `nonce = blake2b256(eph_pk || commitment)[:12]`.\nfunction deriveNonce(ephemeralPk: Uint8Array, commitment: Uint8Array): Uint8Array {\n  ensureLen('ephemeralPk', ephemeralPk, EPHEMERAL_PK_LEN);\n  ensureLen('commitment', commitment, COMMITMENT_LEN);\n  const concat = new Uint8Array(ephemeralPk.length + commitment.length);\n  concat.set(ephemeralPk, 0);\n  concat.set(commitment, ephemeralPk.length);\n  // dkLen 32 default; we slice the first 12 bytes for the AEAD nonce.\n  return blake2b(concat, { dkLen: 32 }).slice(0, NONCE_LEN);\n}\n\n// ── Encrypt / decrypt ────────────────────────────────────────────────────\n\nexport type EncryptedNoteBlob = {\n  /// X25519 ephemeral pubkey published with the blob. Pure scalar mult,\n  /// reveals nothing about the recipient.\n  ephemeralPk: Uint8Array; // 32 bytes\n  /// Plaintext + Poly1305 tag (96 or 128 bytes total).\n  ciphertext: Uint8Array;\n  /// Mirrors the on-chain commitment for AAD binding.\n  commitment: Uint8Array; // 32 bytes\n};\n\nexport type EncryptArgs = {\n  /// Plaintext note to encrypt — recipient's ownerPk + (optional)\n  /// `stealthNonce` are committed to inside.\n  note: DecryptedNote;\n  /// Recipient's X25519 viewing-key pubkey (per ADR-002).\n  recipientViewingPk: Uint8Array; // 32 bytes\n  /// Note commitment (Poseidon4 of value/owner_pk/blinding/memo). Used as\n  /// AEAD AAD + HKDF info — binds the ciphertext to a single commitment.\n  commitment: Uint8Array; // 32 bytes\n  /// Optional ephemeral keypair override (for deterministic test vectors).\n  /// Production code generates fresh randomness per note.\n  ephemeralKeypairOverride?: { sk: Uint8Array; pk: Uint8Array };\n};\n\n/// Encrypt a note for a recipient. Returns the publishable blob fields.\n///\n/// Domain-separation guarantees:\n///   - HKDF info=commitment makes the AEAD key unique per note (a leak\n///     of one key doesn't cascade)\n///   - AAD=commitment makes the ciphertext non-portable (can't be moved\n///     to a different commitment without breaking decryption)\n///   - Deterministic nonce (eph_pk + commitment) avoids the catastrophic\n///     \"weak browser entropy\" path; uniqueness is by construction since\n///     ephemeral_pk is fresh per call.\nexport function encryptNote(args: EncryptArgs): EncryptedNoteBlob {\n  ensureLen('recipientViewingPk', args.recipientViewingPk, 32);\n  ensureLen('commitment', args.commitment, COMMITMENT_LEN);\n\n  let ephSk: Uint8Array;\n  let ephPk: Uint8Array;\n  if (args.ephemeralKeypairOverride) {\n    ephSk = args.ephemeralKeypairOverride.sk;\n    ephPk = args.ephemeralKeypairOverride.pk;\n    ensureLen('ephemeralKeypairOverride.sk', ephSk, 32);\n    ensureLen('ephemeralKeypairOverride.pk', ephPk, 32);\n  } else {\n    ephSk = x25519.utils.randomPrivateKey();\n    ephPk = x25519.getPublicKey(ephSk);\n  }\n\n  const sharedSecret = x25519.getSharedSecret(ephSk, args.recipientViewingPk);\n  const key = deriveSymmetricKey(sharedSecret, args.commitment);\n  const nonce = deriveNonce(ephPk, args.commitment);\n\n  const plaintext = serializeNotePlaintext(args.note);\n  const cipher = chacha20poly1305(key, nonce, args.commitment);\n  const ciphertext = cipher.encrypt(plaintext);\n\n  if (\n    ciphertext.length !== CIPHERTEXT_NO_MEMO\n    && ciphertext.length !== CIPHERTEXT_WITH_MEMO\n  ) {\n    throw new Error(`encryptNote: unexpected ciphertext length ${ciphertext.length}`);\n  }\n  return {\n    ephemeralPk: ephPk,\n    ciphertext,\n    commitment: args.commitment,\n  };\n}\n\n/// Try to decrypt a blob with the given viewing secret. Returns the note\n/// when the auth tag verifies, or `null` when it doesn't (i.e. \"this\n/// blob isn't mine\"). Never throws on tag-mismatch — that's the\n/// expected hot path during note discovery.\nexport function tryDecryptNote(args: {\n  blob: EncryptedNoteBlob;\n  viewingSk: Uint8Array; // 32 bytes\n}): DecryptedNote | null {\n  ensureLen('viewingSk', args.viewingSk, 32);\n  ensureLen('blob.ephemeralPk', args.blob.ephemeralPk, 32);\n  ensureLen('blob.commitment', args.blob.commitment, COMMITMENT_LEN);\n  const len = args.blob.ciphertext.length;\n  if (len !== CIPHERTEXT_NO_MEMO && len !== CIPHERTEXT_WITH_MEMO) {\n    return null;\n  }\n\n  const sharedSecret = x25519.getSharedSecret(args.viewingSk, args.blob.ephemeralPk);\n  const key = deriveSymmetricKey(sharedSecret, args.blob.commitment);\n  const nonce = deriveNonce(args.blob.ephemeralPk, args.blob.commitment);\n  const cipher = chacha20poly1305(key, nonce, args.blob.commitment);\n  let plaintext: Uint8Array;\n  try {\n    plaintext = cipher.decrypt(args.blob.ciphertext);\n  } catch {\n    // Tag verification failed — not our blob, or corrupted.\n    return null;\n  }\n  try {\n    return parseNotePlaintext(plaintext);\n  } catch {\n    // Decrypted but plaintext layout is bad — bail rather than return\n    // garbage. Should never happen on a well-formed blob.\n    return null;\n  }\n}\n\n/// Strict variant: throws on auth-tag mismatch. Use this when the\n/// caller asserts the blob is theirs (e.g. after a successful trial-decrypt\n/// on a different blob, this one MUST match too).\nexport function decryptNote(args: { blob: EncryptedNoteBlob; viewingSk: Uint8Array }): DecryptedNote {\n  const out = tryDecryptNote(args);\n  if (!out) throw new Error('decryptNote: ciphertext failed Poly1305 verification');\n  return out;\n}\n\n// ── X25519 helpers (re-exported so callers don't import @noble directly) ─\n\n/// Generate a fresh X25519 keypair. Used for per-note ephemerals AND\n/// for the user's long-lived viewing key when initialising private balance.\nexport function generateX25519Keypair(): { sk: Uint8Array; pk: Uint8Array } {\n  const sk = x25519.utils.randomPrivateKey();\n  const pk = x25519.getPublicKey(sk);\n  return { sk, pk };\n}\n\n/// Derive an X25519 viewing pubkey from a 32-byte secret. The secret\n/// itself is produced by ADR-002's HKDF-from-wallet-sig path (see\n/// `private-pool-keys.ts`).\nexport function x25519PublicFromSecret(viewingSk: Uint8Array): Uint8Array {\n  ensureLen('viewingSk', viewingSk, 32);\n  return x25519.getPublicKey(viewingSk);\n}\n\n"],"mappings":"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","names":[]}