diff --git a/.gitattributes b/.gitattributes index 838b23c..2949179 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,7 @@ proposals/*.patch text eol=lf # line-ending fold, so a CRLF checkout digests differently from the LF bytes # git stores and the committed-pin tripwire fails on Windows (limit 81). KNOWN-LIMITS.md text eol=lf + +# Shared PAP test vectors are exact bundle bytes checked by signature; any +# line-ending conversion would break them. +test-data/pap-vectors/*.bin binary diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index 15c2b42..9ad2037 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -1,8 +1,8 @@ @@ -1870,6 +1870,40 @@ discipline check in the same spirit as the self-cancelling-leak-grep lesson (2026-07-20), where the tool cannot know what is private and must force the human to say so. +Amended 2026-10-26 (WO-CARTRIDGE-SEAL, sealed mode queued as +`proposals/pap-sealed.patch`, not landed): sealed mode encrypts the spine to +chosen X25519 recipients or a passphrase, and still requires `--public`, because +a sealed QR is still a broadcast. What stays public: the ciphertext, the +ciphertext length (so a rough spine size), the number of recipient keys, whether +a passphrase can open it, the signer's fingerprint, the chain head, the seq and +the timestamp. A passphrase-sealed QR can be attacked offline by anyone who scans +it, as many guesses as they can afford: PBKDF2 at 600,000 iterations slows a +guess, it does not stop a weak passphrase. Seal to keys when it matters. + +Amended 2026-10-26 (wringer review of the sealed-mode patch; sealed mode still +not landed). Three additions, nothing above is withdrawn. First, what a +passphrase needs: PBKDF2 is not memory-hard, so GPUs and ASICs attack it far +faster per dollar than they would a memory-hard KDF. That is the price of using +only WebCrypto primitives so the offline reader can open a bundle at all. The +salt and the iteration count are public in the QR, and a writer cannot choose +fewer than 100,000 rounds (the floor, enforced at seal time; a reader opens +whatever count the bundle carries). Treat the passphrase as the whole secret: +it needs real entropy (a long random one, not a chosen word or phrase), because +the scanned QR gives an attacker unlimited offline guesses for as long as the +bundle exists. Second, a recipient-membership oracle: the stanzas carry no +recipient identifier, and each either authenticates under a candidate secret or +does not. Anyone holding a candidate passphrase, or a candidate recipient +private key such as a leaked or seized one, can test offline against any +scanned bundle whether it opens, without the author knowing. A candidate public +key alone does not give that test (the unwrap needs the secret half); the +bundle itself shows only how many X25519 stanzas it has and whether a +passphrase stanza exists. Third, the sentence above that a signature "proves who +authored the bundle" reads, precisely: the bundle verifies under the key +fingerprint it carries. That is self-consistency. It names an author only to a +reader who compares that fingerprint with one they already trust, or who +supplies the key; `inspect()`, `unpack()` and `pap-boot` report which of the +two happened. + ## 46. A PAP signature proves authorship and integrity, never spine quality or safety A verified PAP signature confirms that the bytes now decoded are the bytes the @@ -1882,6 +1916,12 @@ is the receiving model's. Same class as the standing observation that receipts are behavioral metadata, never intent — a signature is evidence of custody, not of correctness. +Amended 2026-10-26: this holds unchanged for sealed bundles. "Sealed and +signed" proves the bundle is untouched and that the chain key signed it, before +and after encryption. It does not prove the spine is right, and it does not +prove the prediction or workflow inside it was made honestly. When it was signed +is a separate question, and limit 85 answers it. + ## 47. The head hash verifies memoir integrity, never memoir availability If a memoir URL is present in the manifest, the chain head hash lets a reader @@ -3299,3 +3339,45 @@ does see, so each landing carries one signature. That is a convention, not a control: nothing forces it. The fix is core: when a command applies a patch, read the patch's `+++`/`---` targets and classify those paths too, failing closed when the patch file cannot be read. Not staged. + +## 83. A sealed bundle stays sealed only while every recipient key stays secret, and a QR is forever + +Sealed PAP bundles (queued as `proposals/pap-sealed.patch`, not landed) wrap the +content key to each recipient with X25519. There is no forward secrecy: whoever +later obtains any one recipient's secret key opens every bundle ever sealed to +it, and a QR, once printed or posted, can be photographed and kept by anyone +indefinitely. Recipient keys made by `pap-keygen` are stored unencrypted, like +the chain key (limit 8). X25519 is not post-quantum, so a bundle recorded today +is readable by whoever can break X25519 later. Rotating a recipient key protects +future bundles only. (Number may collide with an open PR that also appends a +limit; renumber at merge.) + +## 84. Sealing controls who can open a bundle, not what a recipient does with it + +The inner signature binds the plaintext to its header (signer, chain head, seq, +timestamp, key commitment, every recipient stanza), so a recipient who forwards +the inner bytes to someone else under a new header, even with a valid outer +signature of their own, is caught. A recipient who strips the inner signature +and signs the plaintext as themselves is not caught, and cannot be: that bundle +truthfully names them as its signer, and nothing in the format can tell an +author from a copier who signs first. Likewise, a recipient can publish the +content key, which opens the bundle for everyone and verifies against the +public commitment. That is the reveal feature; it is also a leak. Sealing is +access control at the moment of sharing, never after. (Renumber at merge if +needed.) + +## 85. A bundle's time is the signer's claim; nothing proves it was signed before anything + +The timestamp in a PAP manifest, sealed or not, is whatever the signer's clock +or `--timestamp` said. The chain head proves the bundle was signed no earlier +than that chain entry existed, because the head is a hash nobody could predict. +It says nothing about the latest moment it could have been signed: a signer can +sign today with last week's head and last week's timestamp. "Signed before the +outcome" therefore needs a witness outside the signer, such as the time a third +party received or published the QR, or an external timestamp (limit 3; the +OpenTimestamps anchor is out of scope while the notary is down). The key +commitment closes a different gap: it stops an author showing different +recipients different predictions, not an author backdating one. Nor does it stop +an author sealing ten predictions and revealing only the one that came true; +only publishing every bundle before the outcome, where others can see the count, +does that. (Renumber at merge if needed.) diff --git a/README.md b/README.md index e3ad4d3..6e9a3f9 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,16 @@ npm run grant -- --session --all-pending --max-actions 20 --expires-in-ms 3 **Honest limit.** A grant is reviewed once and spendable up to its ceiling, so a command you approved can run repeatedly without further review. That is the trade, made deliberately. See KNOWN-LIMITS 17. +## Signed QR bundles (PAP) + +`npm run pap:export -- --spine --public` packs a text spine into one QR (2,953 bytes at most), signed with your chain key and stamped with your chain head and seq. `npm run pap:boot -- --bundle ` verifies the signature and prints the spine. + +**What the signature proves, and only that.** Anyone can check that the bundle is untouched and verifies under the key fingerprint it carries. The fingerprint is inside the bundle, so that check alone is self-consistency: it names an author only if you compare that fingerprint with one you already trust, or pass the key in yourself. "Cannot be forged" means "cannot be forged without the chain key file", and that file sits unencrypted on your disk (KNOWN-LIMITS 8, 48): anyone who can read it can sign bundles in your name. A signature proves custody, never that the spine is correct (KNOWN-LIMITS 46). A QR is a broadcast medium; `--public` is required because anyone who scans it reads it (KNOWN-LIMITS 45). + ## Not built yet +- **Sealed bundles.** Encrypt-then-sign, so anyone can verify a bundle and only the recipients you choose can read it. Specified in [docs/PAP-SEALED.md](./docs/PAP-SEALED.md), queued for a signing sitting as `proposals/pap-sealed.patch` because the signing path is core. Not usable until that lands. + - **Scoped custodial integrations.** The sanctioned path for a redacted slice of your record to reach an auditor, a client or your own IT. `npm run export` packages the whole chain with its public key so it verifies off the machine that wrote it, but whole-chain-or-nothing is the only granularity there is. Selective disclosure is the piece that does not exist. Reach out for details. - **External anchoring.** Without a timestamp authority the chain proves alteration but not erasure: truncating the tail leaves a shorter chain that still verifies. See KNOWN-LIMITS 3. - **Hardware-backed key custody.** The chain key sits on disk in plaintext. See KNOWN-LIMITS 8. diff --git a/docs/PAP-SEALED.md b/docs/PAP-SEALED.md new file mode 100644 index 0000000..1c6e7d6 --- /dev/null +++ b/docs/PAP-SEALED.md @@ -0,0 +1,183 @@ +# Sealed PAP bundles (manifest v2) + +Status: **queued for a signing sitting.** The code is `proposals/pap-sealed.patch` +(src/publish and bin/ are core). Until it lands, this file is the spec the patch and +the offline reader implement, and `test/pap-sealed-patch.test.js` proves the patch +on a temp copy inside every `npm test`. + +## What it claims + +Share a workflow sealed and signed in one QR. Anyone can verify it without a key: +that proves the bundle is **unaltered and self-consistent with the key fingerprint +it carries**. The fingerprint is inside the bundle, so verification alone cannot say +who holds that key. It proves authorship only when you **compare the fingerprint to +one you already trust** (the author's published fingerprint, a key you were handed in +person), or pass that key in yourself. Only the recipients the author chose can read +the spine. + +The tools say which of these just happened. `inspect()` and `unpack()` report a +`trust` field, and `pap-boot` prints it: + +| `trust` | The verify key came from | What a pass means | +|---|---|---| +| `self-consistent` | the bundle's own embedded fingerprint | unaltered, and consistent with the fingerprint it carries. Anyone can make a bundle that passes this under a fingerprint of their own. | +| `verified-against-supplied-key` | the caller (`verifyKey`, `--verify-key`) | the same, and that fingerprint is the key you supplied | + +A supplied key must equal the embedded fingerprint (raw 32-byte equality). If it does +not, the call fails with `PAP_SIGNER_MISMATCH` before any signature is checked +(`pap-boot` exits 4). This applies to v1 and v2. + +What it does not claim: that the spine is correct (KNOWN-LIMITS 46), that the +signer's chain key was safe when it signed (KNOWN-LIMITS 8, 48), or that a QR is +private. The ciphertext, the recipient count, the signer, the chain head, the seq +and the timestamp are public (KNOWN-LIMITS 45). + +## Construction + +Encrypt, then sign. Published primitives only, all in both `node:crypto` and WebCrypto: + +| Piece | Primitive | +|---|---| +| Content key (CEK) | 32 random bytes, fresh per bundle | +| Key commitment | `SHA-256("lotor/pap/v2/key-commit" 0x00 CEK)`, public | +| Body | AES-256-GCM, key `HKDF-SHA256(CEK, salt empty, info "lotor/pap/v2/body")`, 12-byte zero nonce, header as associated data | +| X25519 recipient | one ephemeral X25519 key per bundle; `KEK = HKDF-SHA256(X25519(eph, R), salt eph_pub R_pub, info "lotor/pap/v2/x25519-wrap")`; stanza `AES-256-GCM(KEK, zero nonce, CEK)`, 48 B | +| Passphrase | `KEK = PBKDF2-HMAC-SHA256(passphrase, salt16, iterations)`, default 600,000, **floor 100,000 at seal time** (`PAP_KDF_TOO_WEAK` below it); stanza as above | +| Inner binding | Ed25519 by the chain key over `"lotor/pap/v2/inner" 0x00 SHA-256(header) SHA-256(spine) SHA-256(memoirUrl)`, inside the ciphertext | +| Outer signature | Ed25519 by the chain key over `"lotor/pap/v2/outer" 0x00` followed by the whole manifest, appended. v1 signs the bare manifest, unchanged. | + +The zero nonce is safe because each key it is used with is used once: the body key +comes from a fresh CEK, each X25519 KEK from a fresh ephemeral key, each passphrase +KEK from a fresh salt. The same reasoning and shape as age's file-key wrapping. + +**The zero nonce is not reachable from outside.** That reasoning holds only if no +caller can choose the key, so nothing in `seal.js` or `pack.js` is exported that +encrypts under a caller-supplied key. The only way in is a single-use sealer +(`createSealer()`): it makes the CEK, the ephemeral key and every KEK inside its own +closure, returns the public stanzas, and its `encryptBody()` throws on a second call +(`PAP_SEALER_REUSED`). A test pins the exact export surface. Tests that need to build +hostile bundles by hand use `test-data/pap-vectors/seal-primitives.mjs`, a test-only +reimplementation that shares no code with `seal.js`. + +**Why an outer domain string.** The v2 outer signature covers `"lotor/pap/v2/outer" +0x00` plus the manifest, so a v2 signature cannot be valid as anything else, and +cannot be confused with a v1 signature over the same bytes. It is safe without it +today because of the version byte. It costs nothing now and cannot be added after +the format is frozen. + +**Why a key commitment.** AES-GCM is not key-committing: a ciphertext can be made to +authenticate under two different keys, so in principle an author could wrap a +different CEK to each recipient and show each one a different plaintext under one +signature. Against random 256-bit keys that is expensive for the author, so this is +defence in depth, not the only thing standing between a reader and fraud. The +commitment also earns its place on the reveal path: a content key published later is +checked against the one public commitment by anyone. Every reader checks the CEK it +unwrapped against that commitment, so everyone who opens a bundle reads the same +bytes. + +**Passphrase mode, honestly.** The stanza carries a salt and an iteration count, both +public, so anyone who scans the QR can try passphrases offline as fast as they can +afford. PBKDF2 is not memory-hard; it is what WebCrypto offers, which is why the +offline reader can open these bundles at all. Expect to need a passphrase with real +entropy (a long random one, not a word you chose), or seal to a key (KNOWN-LIMITS 45). +The 100,000 floor stops a writer picking a count that makes a stolen QR cheap; it does +not make a weak passphrase safe. + +**Recipient membership is testable by whoever holds the secret.** A stanza carries no +recipient identifier and either authenticates under a candidate secret or it does not. +So anyone holding a candidate passphrase, or a candidate recipient private key (a +leaked or seized one, say), can check offline, against any scanned bundle, whether it +opens, with no cooperation from the author. A candidate public key alone is not enough +for that test: unwrapping needs the secret half (KNOWN-LIMITS 45). What the bundle +does show everyone is how many X25519 stanzas it has and whether a passphrase stanza +exists. + +**Why an inner signature.** The outer signature proves who signed the ciphertext. The +inner one proves the same key signed this plaintext, under this header: these +recipients, this chain head, this seq and time. Anyone who holds a content key can +re-encrypt the plaintext. If they re-address it, or move it under another chain head, +or re-sign the outer as themselves while keeping the inner bytes, the inner check +fails. It cannot stop a recipient who strips the inner signature and signs the +plaintext as themselves; that bundle then truthfully names them as signer +(KNOWN-LIMITS 84). + +**What it cannot prove about time.** The timestamp and chain head are the signer's +claim. The head proves "signed no earlier than this chain entry", never "signed before +the outcome"; that needs an outside witness, and publishing every bundle before the +outcome is the only thing that stops an author sealing several predictions and +revealing the winner (KNOWN-LIMITS 85). + +**Reveal.** Opening returns the CEK. Publishing it later lets anyone open the bundle, +and the public commitment proves it is the right key: commit now, reveal after. + +## Layout (all integers big-endian) + +``` +version u8 0x02 +chainHeadHash 32B +chainPubkeyFp 32B raw Ed25519 public key +seq u32 +timestamp u64 epoch ms +keyCommit 32B +x25519Count u8 +passphraseCount u8 0 or 1 +ephemeralPub 32B only if x25519Count > 0 +x25519 stanzas x25519Count * 48B +passphrase stanza only if passphraseCount = 1: salt 16B, iterations u32, wrapped 48B +---- the header ends here; it is the AEAD associated data ---- +ciphertextLen u16 +ciphertext AES-256-GCM(inner) with 16B tag +---- the manifest ends here ---- +signature 64B Ed25519 over "lotor/pap/v2/outer" 0x00 || manifest + (the domain string is signed, not transmitted) + +inner = innerSig 64B, memoirUrlLen u16, memoirUrl, brotli(spine) +``` + +v1 bundles (`version 0x01`) are unchanged byte for byte, and their signature still +covers the bare manifest. A v1 reader rejects v2 on the version byte; it never +misreads it. (The v1 decoder, `decodeManifest`, rejects any version other than 1; the +tests in `test/pap-pack.test.js` and the shared vectors exercise v1 only.) + +## Reader order + +1. Split off the last 64 bytes. Choose the verify key: the manifest's own embedded + fingerprint (trust: `self-consistent`), or a key the reader supplies, which must + equal that fingerprint (else `PAP_SIGNER_MISMATCH`, before any signature work; + trust: `verified-against-supplied-key`). Verify the outer signature over + `"lotor/pap/v2/outer" 0x00 || manifest`. Fail: altered, or not signed by the key it + carries. Stop. +2. Report: verified, the trust basis, signer fingerprint (compare it with one you + trust), chain head, seq, time, "sealed to N". Do not call this authorship. +3. With a key: unwrap the CEK from a stanza (try each X25519 stanza; the passphrase + stanza). None opens: not a recipient. A malformed key is `PAP_BAD_KEY`, not + "not a recipient". A revealed content key that is not 32 bytes / 64 hex chars is + `PAP_BAD_CONTENT_KEY`. +4. Check the CEK against keyCommit. Mismatch: stop. +5. Decrypt the body with the header as associated data. +6. Decompress the spine with an output cap of 4 MiB (`PAP_SPINE_TOO_LARGE` over it, + `PAP_DECOMPRESS_FAILED` on malformed brotli; v1 uses the same cap); verify the inner + signature with the same key as step 1. Mismatch: stop, it is a swap. + +## Budget (measured, pinned in test/pap-sealed.test.js) + +Against the same spine as a plaintext v1 bundle: + +| Sealed to | Overhead | +|---|---| +| 1 recipient key | 194 B | +| each extra key | +48 B | +| passphrase only | 182 B | +| 1 key + passphrase | 262 B | + +The 194 B is ephemeral key 32, stanza 48, key commitment 32, inner signature 64, GCM +tag 16, counts 2: 194 B. That is more than the work order's estimate of 60 to 80 B, +which did not count the inner signature, the ephemeral key or the commitment. + +One operand is swapped, not added: v2's `u16` `ciphertextLen` takes the place of v1's +`u16` spine-length tag (`SPINE_LEN_TAG_BYTES`), and the memoir URL moves inside the +ciphertext with its own `u16` length, so the line items above are the whole difference +and they sum to 194. Passphrase only is 182: commitment 32, inner signature 64, GCM +tag 16, counts 2, and the passphrase stanza (salt 16, iterations 4, wrapped 48 = 68). +One key plus a passphrase is 194 + 68 = 262. +The QR cap stays 2,953 B and `pack()` refuses over it, naming what sealing cost. diff --git a/proposals/make-pap-sealed-patch.mjs b/proposals/make-pap-sealed-patch.mjs new file mode 100644 index 0000000..c11910d --- /dev/null +++ b/proposals/make-pap-sealed-patch.mjs @@ -0,0 +1,253 @@ +// WO-CARTRIDGE-SEAL part A: generate proposals/pap-sealed.patch from a script, +// never by hand (KNOWN-LIMITS 80). src/publish and bin/ are core +// (src/grant/core-paths.js CORE_DIRS), so sealed mode lands only at a signing +// sitting, one signature per target. +// +// Run from the repo root: node proposals/make-pap-sealed-patch.mjs +// --print-base print the base hashes instead of checking them +// +// What it does: reads the current core files READ ONLY, LF-normalizes them, +// refuses if any differs from the base this proposal was written against, +// builds old/ and new/ trees in a temp dir (new = originals with the +// replacements below, plus the proposed files kept in proposals/pap-sealed/), +// and writes the output of `git diff --no-index old new` with the tree +// prefixes rewritten. Nothing under src/ or bin/ is written. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; + +const LF = String.fromCharCode(10); +const CRLF = String.fromCharCode(13, 10); +const ROOT = process.cwd(); +const PROP = path.join(ROOT, 'proposals', 'pap-sealed'); +const OUT = path.join(ROOT, 'proposals', 'pap-sealed.patch'); + +// The base each modified file was proposed against (sha256 of LF bytes). +// If main moves under one of these, regenerate the proposal, do not force it. +const BASE = { + 'src/publish/manifest.js': '94d959df456102cea67ea1d61835e41163ffd567a765177fc2d0d05829381652', + 'src/publish/pack.js': 'b13c781e39902ce488a397302082b7129eb636f3fec06cc7b5593f7949a1522d', + 'bin/pap-export.js': 'c1604910257d2bc133caf6b000984eaab23c2ae98f6b4144dde6561182859815', + 'bin/pap-boot.js': '0091649b4fa13b6011347a86133fda4babe3152069c6ad5a41481141f093bfb1', +}; + +const read = (rel) => fs.readFileSync(path.join(ROOT, ...rel.split('/')), 'utf8').split(CRLF).join(LF); +const readProp = (name) => fs.readFileSync(path.join(PROP, name), 'utf8').split(CRLF).join(LF); +const sha = (s) => crypto.createHash('sha256').update(s, 'utf8').digest('hex'); + +function replaceOnce(text, label, oldStr, newStr) { + const count = text.split(oldStr).length - 1; + if (count !== 1) { + console.log('assert failed: ' + label + ': old string occurs ' + count + ' times, expected exactly 1'); + process.exit(2); + } + return text.split(oldStr).join(newStr); +} + +if (process.argv.includes('--print-base')) { + for (const rel of Object.keys(BASE)) console.log(" '" + rel + "': '" + sha(read(rel)) + "',"); + process.exit(0); +} + +for (const [rel, want] of Object.entries(BASE)) { + const got = sha(read(rel)); + if (got !== want) { + console.log('base moved: ' + rel + ' is ' + got + ', proposal was written against ' + want); + process.exit(4); + } +} + +// ---- src/publish/manifest.js: header note + the v2 section before exports +const manifestOld = read('src/publish/manifest.js'); +let manifestNew = replaceOnce(manifestOld, 'manifest header', + [' * version byte, never reinterpret v1.', ' */'].join(LF), + [ + ' * version byte, never reinterpret v1.', + ' *', + ' * v2 (sealed, WO-CARTRIDGE-SEAL 2026-10-26) is a separate layout under its', + ' * own version byte; see docs/PAP-SEALED.md and the v2 section at the end of', + ' * this file. decodeManifest() still accepts v1 only, exactly as before.', + ' */', + ].join(LF)); +const manifestExportsOld = [ + 'export {', + ' VERSION,', + ' HEADER_FIXED_LEN,', + ' SPINE_LEN_TAG_BYTES,', + ' SIG_LEN,', + ' QR_V40_BINARY_ECL_CAPACITY,', + ' USEFUL_PAYLOAD_BUDGET,', + ' encodeManifest,', + ' decodeManifest,', + '};', + '', +].join(LF); +manifestNew = replaceOnce(manifestNew, 'manifest exports', manifestExportsOld, readProp('manifest.v2-block.txt')); + +// ---- src/publish/pack.js +const packOld = read('src/publish/pack.js'); +let packNew = replaceOnce(packOld, 'pack header', + ' * module stays testable with synthetic keys.' + LF, + [ + ' * module stays testable with synthetic keys.', + ' *', + ' * Sealed mode (v2, WO-CARTRIDGE-SEAL 2026-10-26): pack() with inputs.seal', + ' * encrypts, then signs. See the sealed section below and docs/PAP-SEALED.md.', + ' * Without inputs.seal, pack() and unpack() are the v1 code, byte for byte.', + '', + ].join(LF)); +packNew = replaceOnce(packNew, 'pack imports', + [ + 'import {', + ' encodeManifest,', + ' decodeManifest,', + ' SIG_LEN,', + ' QR_V40_BINARY_ECL_CAPACITY,', + "} from './manifest.js';", + ].join(LF), + [ + 'import {', + ' encodeManifest,', + ' decodeManifest,', + ' encodeSealedHeader,', + ' encodeSealedManifest,', + ' decodeSealedManifest,', + ' SIG_LEN,', + ' QR_V40_BINARY_ECL_CAPACITY,', + ' HEADER_FIXED_LEN,', + ' SPINE_LEN_TAG_BYTES,', + ' VERSION_SEALED,', + "} from './manifest.js';", + 'import {', + ' createSealer,', + ' commitmentMatches,', + ' aeadOpen,', + ' openBody,', + ' generateRecipientKeypair,', + ' parseRecipientPrivateKey,', + ' x25519Unwrap,', + ' passphraseKek,', + ' innerMessage,', + ' outerMessage,', + "} from './seal.js';", + ].join(LF)); +// v1 unpack(): same bytes in and out; the signer check, the capped decompress +// and the trust field now come from the shared helpers in the sealed block. +packNew = replaceOnce(packNew, 'v1 unpack verify', + [ + ' const verifyKey = inputs.verifyKey || rawEd25519PublicKeyToKeyObject(manifest.chainPubkeyFp);', + ' const ok = crypto.verify(null, manifestBytes, verifyKey, signature);', + ' if (!ok) {', + " const err = new Error('signature verification failed: bundle has been altered or was signed by a different key');", + " err.code = 'PAP_SIGNATURE_INVALID';", + ' throw err;', + ' }', + ].join(LF), + [ + ' const { verifyKey, trust } = resolveVerifier(inputs.verifyKey, manifest.chainPubkeyFp);', + ' if (!crypto.verify(null, manifestBytes, verifyKey, signature)) throw signatureInvalid();', + ].join(LF)); +packNew = replaceOnce(packNew, 'v1 unpack decompress', + ' const spineRaw = zlib.brotliDecompressSync(manifest.spineCompressed);', + ' const spineRaw = boundedBrotli(manifest.spineCompressed);'); +packNew = replaceOnce(packNew, 'v1 unpack result', + [' spineCompressedLen: manifest.spineCompressed.length,', ' };'].join(LF), + [' spineCompressedLen: manifest.spineCompressed.length,', ' trust,', ' };'].join(LF)); +packNew = replaceOnce(packNew, 'v1 unpack doc', + [' * @returns {{ manifest: Object, spine: string, spineRawLen: number,', ' * spineCompressedLen: number }}'].join(LF), + [ + ' * @returns {{ manifest: Object, spine: string, spineRawLen: number,', + ' * spineCompressedLen: number, trust: string }}', + ' * trust is "self-consistent" when the key came from the bundle itself,', + ' * "verified-against-supplied-key" when the caller passed verifyKey (which', + ' * must equal the embedded fingerprint, else PAP_SIGNER_MISMATCH).', + ].join(LF)); +packNew = replaceOnce(packNew, 'pack dispatch', + ['function pack(inputs) {', ' if (typeof inputs.spine'].join(LF), + ['function pack(inputs) {', ' if (inputs.seal) return packSealed(inputs);', ' if (typeof inputs.spine'].join(LF)); +packNew = replaceOnce(packNew, 'unpack dispatch', + [' throw new Error(`bundle too short: ${inputs.bundle.length} bytes`);', ' }', ' const manifestBytes'].join(LF), + [ + ' throw new Error(`bundle too short: ${inputs.bundle.length} bytes`);', + ' }', + ' if (inputs.bundle[0] === VERSION_SEALED) return unpackSealed(inputs);', + ' const manifestBytes', + ].join(LF)); +packNew = replaceOnce(packNew, 'sealed block', + '// ---------- internal ----------' + LF, + readProp('pack.sealed-block.txt') + '// ---------- internal ----------' + LF); +packNew = replaceOnce(packNew, 'pack exports', + ['export {', ' pack,', ' unpack,', ' generateKeypair,', '};'].join(LF), + ['export {', ' pack,', ' unpack,', ' inspect,', ' generateKeypair,', ' generateRecipientKeypair,', ' MAX_SPINE_RAW_BYTES,', '};'].join(LF)); + +// ---- whole-file proposals (base-hash guarded above, or new) +// +// Three lines of the bin files on main carry an em dash. The proposed copies +// cannot (the PR guard rejects any added em dash), and a patch that removed +// them would itself contain them on its minus lines. So the proposed copies +// hold dash-free versions and the original lines are put back here: the +// patch leaves those three lines exactly as they are on main. +const EM = String.fromCharCode(0x2014); +const RESTORE = { + 'bin/pap-boot.js': [ + [' * pap-boot: decode a signed bundle back into a spine.', ' * pap-boot ' + EM + ' decode a signed bundle back into a spine.'], + ], + 'bin/pap-export.js': [ + [' * pap-export: encode a spine into a signed QR bundle.', ' * pap-export ' + EM + ' encode a spine into a signed QR bundle.'], + [' * keys without touching a real chain, the same seam the prototype proved.', ' * keys without touching a real chain ' + EM + ' same seam the prototype proved.'], + ], +}; +function withRestored(rel, text) { + let out = text; + for (const [dashless, original] of RESTORE[rel] || []) { + if (!read(rel).includes(original)) { + console.log('assert failed: ' + rel + ' no longer has the line to restore: ' + original); + process.exit(2); + } + out = replaceOnce(out, rel + ' restore', dashless, original); + } + return out; +} + +const files = { + 'src/publish/manifest.js': [manifestOld, manifestNew], + 'src/publish/pack.js': [packOld, packNew], + 'src/publish/seal.js': [null, readProp('src.publish.seal.js')], + 'bin/pap-export.js': [read('bin/pap-export.js'), withRestored('bin/pap-export.js', readProp('bin.pap-export.js'))], + 'bin/pap-boot.js': [read('bin/pap-boot.js'), withRestored('bin/pap-boot.js', readProp('bin.pap-boot.js'))], + 'bin/pap-keygen.js': [null, readProp('bin.pap-keygen.js')], +}; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pap-sealed-patch-')); +try { + for (const [rel, [oldText, newText]] of Object.entries(files)) { + for (const [side, text] of [['old', oldText], ['new', newText]]) { + if (text === null) continue; + const p = path.join(tmp, side, ...rel.split('/')); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, text); + } + } + fs.mkdirSync(path.join(tmp, 'old'), { recursive: true }); + const r = spawnSync('git', ['-c', 'core.autocrlf=false', 'diff', '--no-index', '--no-color', 'old', 'new'], + { cwd: tmp, encoding: 'utf8' }); + if (r.status !== 1) { + console.log('git diff exited ' + r.status + ', expected 1. ' + (r.stderr || '') + (r.error ? String(r.error) : '')); + process.exit(3); + } + const patch = r.stdout.split(LF) + .filter((line) => !line.startsWith('index ')) + .map((line) => line + .split(' a/old/').join(' a/').split(' b/new/').join(' b/') + .split(' a/new/').join(' a/') + .split('--- a/old/').join('--- a/').split('+++ b/new/').join('+++ b/')) + .join(LF); + fs.writeFileSync(OUT, patch); + console.log('wrote proposals/pap-sealed.patch, ' + fs.statSync(OUT).size + ' bytes, ' + + Object.keys(files).length + ' files'); +} finally { + fs.rmSync(tmp, { recursive: true, force: true }); +} diff --git a/proposals/pap-sealed.patch b/proposals/pap-sealed.patch new file mode 100644 index 0000000..381dbfd --- /dev/null +++ b/proposals/pap-sealed.patch @@ -0,0 +1,1375 @@ +diff --git a/bin/pap-boot.js b/bin/pap-boot.js +--- a/bin/pap-boot.js ++++ b/bin/pap-boot.js +@@ -4,17 +4,38 @@ + * + * Usage: + * node bin/pap-boot.js --bundle [--verify-key ] ++ * node bin/pap-boot.js --bundle --recipient-key ++ * node bin/pap-boot.js --bundle --passphrase-env ++ * node bin/pap-boot.js --bundle --content-key + * + * Reads bundle bytes from --bundle (or stdin if the path is `-`), +- * verifies the signature (against --verify-key if supplied, otherwise +- * against the pubkey embedded in the manifest), decompresses the spine, +- * and writes it to stdout. ++ * verifies the signature, decompresses the spine, and writes it to stdout. ++ * ++ * What "verified" means is printed as a trust line. With no --verify-key the ++ * signature is checked against the pubkey the bundle itself carries, so the ++ * result is "self-consistent": the bundle is unaltered and agrees with its own ++ * fingerprint. That proves who signed it only if you compare the printed ++ * fingerprint with one you already trust. With --verify-key the key must BE ++ * the bundle's embedded fingerprint, else exit 4 (signer mismatch), and the ++ * trust line reads "verified-against-supplied-key". ++ * ++ * A sealed (v2) bundle verifies without a key: with no key flag, pap-boot ++ * prints "verified, sealed to N" and exits 5 with nothing on stdout. A ++ * recipient key, the passphrase, or the revealed content key opens it. ++ * --print-content-key writes the content key to stderr after opening, so a ++ * recipient can reveal the bundle to everyone later; the bundle's public key ++ * commitment lets anyone check that a revealed key is the right one. + * + * Exit codes: + * 0 on success + * 1 on I/O error or unexpected failure +- * 2 on bad arguments +- * 4 on signature verification failure (limit of the record itself) ++ * 2 on bad arguments (including a malformed recipient or content key) ++ * 4 on signature verification failure, or a --verify-key that is not the ++ * bundle's embedded signer (limit of the record itself) ++ * 5 sealed bundle verified, but no key given to open it ++ * 6 sealed bundle verified, but the key does not open it ++ * 7 bundle failed its key commitment, decrypt or inner binding check, or ++ * its spine would expand past the cap or is not valid brotli + * + * A separate decoder-from-image tool is out of scope for v1 (see + * src/publish/qr.js). The boot spec (BOOT-SPEC.md in the WO-PAP-01 +@@ -34,6 +55,27 @@ import fs from 'node:fs'; + import crypto from 'node:crypto'; + import { unpack } from '../src/publish/pack.js'; + ++const SEALED_EXIT = { ++ PAP_SEALED_NEEDS_KEY: 5, ++ PAP_NOT_A_RECIPIENT: 6, ++ PAP_KEY_COMMIT_MISMATCH: 7, ++ PAP_DECRYPT_FAILED: 7, ++ PAP_INNER_BINDING_INVALID: 7, ++ PAP_SPINE_TOO_LARGE: 7, ++ PAP_DECOMPRESS_FAILED: 7, ++ PAP_KDF_BAD_PARAMS: 7, ++ PAP_BAD_KEY: 2, ++ PAP_BAD_CONTENT_KEY: 2, ++}; ++ ++function trustLine(trust) { ++ if (trust === 'verified-against-supplied-key') { ++ return ' trust verified-against-supplied-key (matches the key given with --verify-key)\n'; ++ } ++ return ' trust self-consistent (verified against the fingerprint the bundle carries; ' + ++ 'compare the fingerprint with one you trust before relying on who signed it)\n'; ++} ++ + function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.bundle) die('missing --bundle (or --bundle - for stdin)'); +@@ -49,18 +91,37 @@ function main() { + }) + : undefined; + ++ const opener = {}; ++ if (args.recipientKey) { ++ opener.recipientKey = /^[0-9a-f]{64}$/i.test(args.recipientKey) ++ ? args.recipientKey ++ : fs.readFileSync(args.recipientKey, 'utf8').trim(); ++ } ++ if (args.passphraseEnv) { ++ const p = process.env[args.passphraseEnv]; ++ if (!p) die(`--passphrase-env ${args.passphraseEnv}: that environment variable is empty or unset`); ++ opener.passphrase = p; ++ } ++ if (args.contentKey) { ++ if (!/^[0-9a-f]{64}$/i.test(args.contentKey)) die('--content-key must be 64 hex characters'); ++ opener.contentKey = args.contentKey; ++ } ++ + try { +- const { manifest, spine, spineRawLen, spineCompressedLen } = unpack({ bundle, verifyKey }); ++ const { manifest, spine, spineRawLen, spineCompressedLen, contentKey, trust } = unpack({ bundle, verifyKey, ...opener }); + process.stdout.write(spine); + process.stderr.write( + `pap-boot ok\n` + + ` version ${manifest.version}\n` + ++ (manifest.sealed ? ` sealed to ${describe(manifest.recipients)}, opened\n` : '') + + ` seq ${manifest.seq}\n` + + ` timestamp ${manifest.timestamp} (${new Date(manifest.timestamp).toISOString()})\n` + + ` chain head ${manifest.chainHeadHash.toString('hex')}\n` + + ` chain pubkey fp ${manifest.chainPubkeyFp.toString('hex')}\n` + ++ trustLine(trust) + + (manifest.memoirUrl ? ` memoir ${manifest.memoirUrl}\n` : '') + +- ` raw ${spineRawLen} B, compressed ${spineCompressedLen} B\n`, ++ ` raw ${spineRawLen} B, compressed ${spineCompressedLen} B\n` + ++ (args.printContentKey && contentKey ? ` content key ${contentKey.toString('hex')}\n` : ''), + ); + process.exit(0); + } catch (err) { +@@ -68,11 +129,36 @@ function main() { + process.stderr.write('pap-boot: ' + err.message + '\n'); + process.exit(4); + } ++ if (err.code === 'PAP_SIGNER_MISMATCH') { ++ process.stderr.write('pap-boot: signer mismatch: ' + err.message + '\n'); ++ process.exit(4); ++ } ++ if (err.code in SEALED_EXIT) { ++ const i = err.info; ++ process.stderr.write( ++ 'pap-boot: ' + err.message + '\n' + ++ (i ++ ? ` seq ${i.seq}\n` + ++ ` timestamp ${i.timestamp} (${new Date(i.timestamp).toISOString()})\n` + ++ ` chain head ${i.chainHeadHash.toString('hex')}\n` + ++ ` chain pubkey fp ${i.chainPubkeyFp.toString('hex')}\n` + ++ trustLine(i.trust) ++ : ''), ++ ); ++ process.exit(SEALED_EXIT[err.code]); ++ } + process.stderr.write('pap-boot: ' + (err && err.stack || err) + '\n'); + process.exit(1); + } + } + ++function describe(r) { ++ const parts = []; ++ if (r.x25519 > 0) parts.push(`${r.x25519} recipient key(s)`); ++ if (r.passphrase) parts.push('a passphrase'); ++ return parts.join(' and '); ++} ++ + function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { +@@ -80,6 +166,10 @@ function parseArgs(argv) { + switch (a) { + case '--bundle': out.bundle = argv[++i]; break; + case '--verify-key': out.verifyKey = argv[++i]; break; ++ case '--recipient-key': out.recipientKey = argv[++i]; break; ++ case '--passphrase-env': out.passphraseEnv = argv[++i]; break; ++ case '--content-key': out.contentKey = argv[++i]; break; ++ case '--print-content-key': out.printContentKey = true; break; + case '-h': case '--help': out.help = true; break; + default: + if (!a.startsWith('-')) break; +diff --git a/bin/pap-export.js b/bin/pap-export.js +--- a/bin/pap-export.js ++++ b/bin/pap-export.js +@@ -11,6 +11,12 @@ + * --out output basepath (default .pap) + * --public REQUIRED acknowledgment (see below) + * ++ * Sealed mode (manifest v2, docs/PAP-SEALED.md): ++ * --seal encrypt the spine; only the recipients can read it ++ * --to X25519 recipient public key: 64 hex chars or a ++ * path to a .pub / SPKI PEM file. Repeat per recipient. ++ * --passphrase-env also seal to the passphrase held in env var VAR ++ * + * Chain-state overrides (default: read live from the Lotor home): + * --key Ed25519 private key (default: the chain key) + * --chain-head chain head hash (default: current head) +@@ -25,12 +31,18 @@ + * + * The --public flag is required and unlocks nothing except the operator's + * acknowledgment that everything about to be encoded is going onto a +- * medium anyone can scan. See KNOWN-LIMITS 45. The signature proves who +- * authored the bundle, not that the bundle is safe to distribute. ++ * medium anyone can scan. See KNOWN-LIMITS 45. The signature ties the bundle ++ * to the key fingerprint it carries; it does not make the bundle safe to ++ * distribute, and it names the author only to a reader who compares that ++ * fingerprint with one they trust. Sealed ++ * mode still needs --public: the ciphertext, the recipient count, the ++ * signer, the chain head, the seq and the timestamp stay readable by anyone. + * + * Signing uses the CHAIN key, not the approval key (KNOWN-LIMITS 48): + * bundle authenticity is chain-key strength, and the chain key is stored +- * plaintext at rest (limit 8). ++ * plaintext at rest (limit 8). "Cannot be forged" means "cannot be forged ++ * without the chain key file", and anyone who can read that file can sign ++ * bundles in your name. + * + * Emits .png, .svg, and .bin. + * +@@ -46,18 +58,71 @@ import { bundleToPng, bundleToSvg } from '../src/publish/qr.js'; + import { resolveHome } from '../src/home.js'; + import { loadChain, loadOrCreateKeyPair } from '../src/store/index.js'; + ++const USAGE = [ ++ 'usage: node bin/pap-export.js --spine --public [options]', ++ '', ++ ' --spine spine text file to encode (required)', ++ ' --public acknowledge a QR is a broadcast medium (required)', ++ ' --memoir-url optional memoir pointer', ++ ' --out output basepath (default .pap)', ++ '', ++ 'sealed mode (only the recipients can read the spine):', ++ ' --seal encrypt, then sign', ++ ' --to X25519 recipient public key; repeat per recipient', ++ ' (make one with: node bin/pap-keygen.js --out )', ++ ' --passphrase-env also seal to the passphrase in env var VAR', ++ '', ++ 'chain-state overrides: --key --chain-head --chain-pubkey-fp ', ++ ' --seq --timestamp ', ++ '', ++ 'What a bundle proves, and what it does not:', ++ ' Anyone can check that the bundle is untouched and verifies under the key', ++ ' fingerprint it carries. That names you only to a reader who compares the', ++ ' fingerprint with one they already trust, and only while the chain key is', ++ ' secret. The chain key is stored unencrypted on this machine (KNOWN-LIMITS 8, 48):', ++ ' anyone who can read the key file can sign bundles in your name.', ++ ' A signature proves custody, never that the spine is correct (KNOWN-LIMITS 46).', ++ ' Sealed mode hides the spine, not the fact of the bundle: the ciphertext,', ++ ' recipient count, signer, chain head, seq and time are public (KNOWN-LIMITS 45).', ++].join('\n'); ++ + async function main() { + const args = parseArgs(process.argv.slice(2)); + ++ if (args.help) { ++ process.stdout.write(USAGE + '\n'); ++ process.exit(0); ++ } ++ + if (!args.public) { + die( + 'refusing to encode without --public.\n' + + 'A QR is a broadcast medium. Anyone who scans this will read the spine.\n' + ++ (args.seal ++ ? 'Sealed, they cannot read the spine, but they can read the ciphertext, the\n' + ++ 'recipient count, the signer, the chain head, the seq and the timestamp.\n' ++ : '') + + 'Add --public to acknowledge and proceed.', + ); + } + if (!args.spine) die('missing --spine '); + ++ let seal; ++ if (args.seal) { ++ const to = args.to.map(readRecipientKey); ++ let passphrase; ++ if (args.passphraseEnv) { ++ passphrase = process.env[args.passphraseEnv]; ++ if (!passphrase) die(`--passphrase-env ${args.passphraseEnv}: that environment variable is empty or unset`); ++ } ++ if (to.length === 0 && passphrase === undefined) { ++ die('--seal needs at least one --to or --passphrase-env '); ++ } ++ seal = { to, passphrase }; ++ } else if (args.to.length > 0 || args.passphraseEnv) { ++ die('--to and --passphrase-env only make sense with --seal'); ++ } ++ + const spine = fs.readFileSync(args.spine, 'utf8'); + + // Chain-state defaults: read the live Lotor home unless every field is +@@ -102,6 +167,7 @@ async function main() { + timestamp, + memoirUrl: args.memoirUrl || '', + signingKey, ++ seal, + }); + + const basepath = args.out || path.resolve(args.spine + '.pap'); +@@ -113,10 +179,15 @@ async function main() { + fs.writeFileSync(basepath + '.svg', svg); + fs.writeFileSync(basepath + '.bin', result.bundle); + ++ const sealedLines = result.sealed ++ ? ` sealed to ${result.recipients.x25519} recipient key(s)${result.recipients.passphrase ? ' + a passphrase' : ''}` + ++ ` (sealing cost ${result.sealOverhead} B)\n` ++ : ''; + console.log( + `pap-export ok\n` + + ` spine raw ${result.spineRawLen} B\n` + + ` compressed ${result.spineCompressedLen} B (${result.compressionRatio.toFixed(2)}x)\n` + ++ sealedLines + + ` bundle ${result.totalLen} B / budget ${result.budget} B (headroom ${result.headroom} B)\n` + + ` chain seq ${seq}\n` + + ` wrote ${basepath}.png\n` + +@@ -126,7 +197,7 @@ async function main() { + } + + function parseArgs(argv) { +- const out = { public: false }; ++ const out = { public: false, seal: false, to: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + switch (a) { +@@ -139,6 +210,9 @@ function parseArgs(argv) { + case '--memoir-url': out.memoirUrl = argv[++i]; break; + case '--timestamp': out.timestamp = argv[++i]; break; + case '--out': out.out = argv[++i]; break; ++ case '--seal': out.seal = true; break; ++ case '--to': out.to.push(argv[++i]); break; ++ case '--passphrase-env': out.passphraseEnv = argv[++i]; break; + case '-h': case '--help': out.help = true; break; + default: + if (!a.startsWith('-')) break; +@@ -148,6 +222,15 @@ function parseArgs(argv) { + return out; + } + ++// A recipient key on the command line is either 64 hex chars or a file ++// holding hex (a .pub from pap-keygen) or an SPKI PEM. pack() validates it. ++function readRecipientKey(s) { ++ if (typeof s !== 'string' || s.length === 0) die('--to needs a key or a key file'); ++ if (/^[0-9a-f]{64}$/i.test(s)) return s; ++ if (!fs.existsSync(s)) die(`--to ${s}: not 64 hex characters and not a readable file`); ++ return fs.readFileSync(s, 'utf8').trim(); ++} ++ + function hex32(s, name) { + if (typeof s !== 'string' || !/^[0-9a-f]{64}$/i.test(s)) { + die(`${name} must be 64 hex characters (32 bytes); got ${s}`); +@@ -165,6 +248,10 @@ main().catch((err) => { + process.stderr.write('pap-export: ' + err.message + '\n'); + process.exit(3); + } ++ if (err && (err.code === 'PAP_BAD_KEY' || err.code === 'PAP_SPINE_TOO_LARGE' || err.code === 'PAP_KDF_TOO_WEAK')) { ++ process.stderr.write('pap-export: ' + err.message + '\n'); ++ process.exit(2); ++ } + process.stderr.write('pap-export: ' + (err && err.stack || err) + '\n'); + process.exit(1); + }); +diff --git a/bin/pap-keygen.js b/bin/pap-keygen.js +new file mode 100644 +--- /dev/null ++++ b/bin/pap-keygen.js +@@ -0,0 +1,47 @@ ++#!/usr/bin/env node ++/** ++ * pap-keygen: make an X25519 recipient keypair for sealed PAP bundles. ++ * ++ * Usage: ++ * node bin/pap-keygen.js --out ++ * ++ * Writes .key (PKCS8 PEM, the secret; keep it) and .pub ++ * (64 hex chars; hand it to whoever seals bundles for you). Refuses to ++ * overwrite either file. ++ * ++ * A recipient key is not the chain key and signs nothing. It only opens ++ * bundles sealed to it. Anyone who later obtains the .key file opens every ++ * bundle ever sealed to it, and those bundles are QR codes that may have ++ * been photographed anywhere (KNOWN-LIMITS, sealed-mode entry). Treat it ++ * like the chain key: it is stored unencrypted, here too. ++ */ ++ ++import fs from 'node:fs'; ++import { generateRecipientKeypair } from '../src/publish/pack.js'; ++ ++function main() { ++ const argv = process.argv.slice(2); ++ let out; ++ for (let i = 0; i < argv.length; i++) { ++ if (argv[i] === '--out') out = argv[++i]; ++ else if (argv[i] === '-h' || argv[i] === '--help') { ++ process.stdout.write('usage: node bin/pap-keygen.js --out \n'); ++ process.exit(0); ++ } else die(`unknown argument: ${argv[i]}`); ++ } ++ if (!out) die('missing --out '); ++ for (const p of [out + '.key', out + '.pub']) { ++ if (fs.existsSync(p)) die(`${p} exists; refusing to overwrite a key`); ++ } ++ const kp = generateRecipientKeypair(); ++ fs.writeFileSync(out + '.key', kp.privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 }); ++ fs.writeFileSync(out + '.pub', kp.publicKeyRaw.toString('hex') + '\n'); ++ process.stdout.write(`pap-keygen ok\n secret ${out}.key\n public ${out}.pub ${kp.publicKeyRaw.toString('hex')}\n`); ++} ++ ++function die(msg) { ++ process.stderr.write('pap-keygen: ' + msg + '\n'); ++ process.exit(2); ++} ++ ++main(); +diff --git a/src/publish/manifest.js b/src/publish/manifest.js +--- a/src/publish/manifest.js ++++ b/src/publish/manifest.js +@@ -30,6 +30,10 @@ + * built 2026-08-07, 13/13 tests green) with a CJS-to-ESM conversion only. + * The byte layout is spec-frozen in BOOT-SPEC.md; layout changes bump the + * version byte, never reinterpret v1. ++ * ++ * v2 (sealed, WO-CARTRIDGE-SEAL 2026-10-26) is a separate layout under its ++ * own version byte; see docs/PAP-SEALED.md and the v2 section at the end of ++ * this file. decodeManifest() still accepts v1 only, exactly as before. + */ + + const VERSION = 0x01; +@@ -177,13 +181,181 @@ function readUInt64BE(buf, offset) { + return (hi << 32n) | lo; + } + ++// ---------- v2: sealed ---------- ++// ++// v2 layout, in order (all integers big-endian; spec docs/PAP-SEALED.md): ++// version u8 = 0x02 ++// chainHeadHash 32B ++// chainPubkeyFp 32B ++// seq u32 ++// timestamp u64 ++// keyCommit 32B SHA-256 commitment to the content key ++// x25519Count u8 number of X25519 recipient stanzas ++// passphraseCount u8 0 or 1 ++// ephemeralPub 32B present only when x25519Count > 0 ++// x25519Wrapped x25519Count * 48B (wrapped content key + GCM tag) ++// passphrase stanza present only when passphraseCount = 1: ++// salt 16B, iterations u32, wrapped 48B ++// ---- everything above is the header, and is the AEAD's associated data ---- ++// ciphertextLen u16 ++// ciphertext AES-256-GCM output with the 16-byte tag appended ++// ++// The outer Ed25519 signature (64B, appended by pack.js) covers all of it, ++// prefixed by the domain string "lotor/pap/v2/outer" and one 0x00 byte. The ++// prefix is not part of the layout and not on the wire; v1 signs the bare ++// manifest and is unchanged. ++ ++const VERSION_SEALED = 0x02; ++const KEY_COMMIT_LEN = 32; ++const EPHEMERAL_PUB_LEN = 32; ++const WRAPPED_KEY_LEN = 48; ++const PASSPHRASE_SALT_LEN = 16; ++const SEALED_FIXED_LEN = 1 + CHAIN_HEAD_HASH_LEN + CHAIN_PUBKEY_FP_LEN + 4 + 8 + KEY_COMMIT_LEN + 1 + 1; ++ ++/** ++ * Encode the v2 header (the AEAD associated data). Does NOT sign or encrypt. ++ */ ++function encodeSealedHeader(h) { ++ requireBufferOfLength(h.chainHeadHash, CHAIN_HEAD_HASH_LEN, 'chainHeadHash'); ++ requireBufferOfLength(h.chainPubkeyFp, CHAIN_PUBKEY_FP_LEN, 'chainPubkeyFp'); ++ requireU32(h.seq, 'seq'); ++ requireSafeInteger(h.timestamp, 'timestamp'); ++ requireBufferOfLength(h.keyCommit, KEY_COMMIT_LEN, 'keyCommit'); ++ const wrapped = h.x25519Wrapped || []; ++ if (wrapped.length > 0xFF) throw new Error(`too many recipients: ${wrapped.length}, max 255`); ++ if (wrapped.length > 0) requireBufferOfLength(h.ephemeralPub, EPHEMERAL_PUB_LEN, 'ephemeralPub'); ++ for (const w of wrapped) requireBufferOfLength(w, WRAPPED_KEY_LEN, 'x25519 wrapped key'); ++ const p = h.passphrase || null; ++ if (p) { ++ requireBufferOfLength(p.salt, PASSPHRASE_SALT_LEN, 'passphrase salt'); ++ requireU32(p.iterations, 'passphrase iterations'); ++ requireBufferOfLength(p.wrapped, WRAPPED_KEY_LEN, 'passphrase wrapped key'); ++ } ++ if (wrapped.length === 0 && !p) throw new Error('a sealed bundle needs at least one recipient or a passphrase'); ++ ++ const fixed = Buffer.alloc(SEALED_FIXED_LEN); ++ let off = 0; ++ fixed.writeUInt8(VERSION_SEALED, off); off += 1; ++ h.chainHeadHash.copy(fixed, off); off += CHAIN_HEAD_HASH_LEN; ++ h.chainPubkeyFp.copy(fixed, off); off += CHAIN_PUBKEY_FP_LEN; ++ fixed.writeUInt32BE(h.seq, off); off += 4; ++ writeUInt64BE(fixed, BigInt(h.timestamp), off); off += 8; ++ h.keyCommit.copy(fixed, off); off += KEY_COMMIT_LEN; ++ fixed.writeUInt8(wrapped.length, off); off += 1; ++ fixed.writeUInt8(p ? 1 : 0, off); off += 1; ++ const parts = [fixed]; ++ if (wrapped.length > 0) parts.push(h.ephemeralPub, ...wrapped); ++ if (p) { ++ const iter = Buffer.alloc(4); ++ iter.writeUInt32BE(p.iterations, 0); ++ parts.push(p.salt, iter, p.wrapped); ++ } ++ return Buffer.concat(parts); ++} ++ ++/** ++ * Header || u16 ciphertextLen || ciphertext. Does NOT sign. ++ */ ++function encodeSealedManifest(headerBytes, ciphertext) { ++ requireBuffer(headerBytes, 'headerBytes'); ++ requireBuffer(ciphertext, 'ciphertext'); ++ if (ciphertext.length > 0xFFFF) { ++ throw new Error(`ciphertext too long: ${ciphertext.length} bytes, max 65535`); ++ } ++ const len = Buffer.alloc(2); ++ len.writeUInt16BE(ciphertext.length, 0); ++ return Buffer.concat([headerBytes, len, ciphertext]); ++} ++ ++/** ++ * Decode a v2 manifest. Returns the public fields plus headerBytes (the AEAD ++ * associated data) and the ciphertext. Nothing here needs a key. ++ */ ++function decodeSealedManifest(bytes) { ++ requireBuffer(bytes, 'manifest bytes'); ++ if (bytes.length < SEALED_FIXED_LEN + 2) { ++ throw new Error(`sealed manifest too short: ${bytes.length} bytes, need at least ${SEALED_FIXED_LEN + 2}`); ++ } ++ let off = 0; ++ const version = bytes.readUInt8(off); off += 1; ++ if (version !== VERSION_SEALED) { ++ throw new Error(`unknown sealed manifest version: 0x${version.toString(16)}, expected 0x${VERSION_SEALED.toString(16)}`); ++ } ++ const chainHeadHash = Buffer.from(bytes.subarray(off, off + CHAIN_HEAD_HASH_LEN)); off += CHAIN_HEAD_HASH_LEN; ++ const chainPubkeyFp = Buffer.from(bytes.subarray(off, off + CHAIN_PUBKEY_FP_LEN)); off += CHAIN_PUBKEY_FP_LEN; ++ const seq = bytes.readUInt32BE(off); off += 4; ++ const timestampBig = readUInt64BE(bytes, off); off += 8; ++ const timestamp = Number(timestampBig); ++ if (!Number.isSafeInteger(timestamp)) { ++ throw new Error(`timestamp ${timestampBig} exceeds safe integer range`); ++ } ++ const keyCommit = Buffer.from(bytes.subarray(off, off + KEY_COMMIT_LEN)); off += KEY_COMMIT_LEN; ++ const x25519Count = bytes.readUInt8(off); off += 1; ++ const passphraseCount = bytes.readUInt8(off); off += 1; ++ if (passphraseCount > 1) throw new Error(`passphraseCount must be 0 or 1, got ${passphraseCount}`); ++ if (x25519Count === 0 && passphraseCount === 0) throw new Error('sealed manifest has no recipients'); ++ ++ const need = (n, what) => { ++ if (off + n > bytes.length) throw new Error(`sealed manifest ends early inside ${what}`); ++ }; ++ let ephemeralPub = null; ++ const x25519Wrapped = []; ++ if (x25519Count > 0) { ++ need(EPHEMERAL_PUB_LEN, 'ephemeralPub'); ++ ephemeralPub = Buffer.from(bytes.subarray(off, off + EPHEMERAL_PUB_LEN)); off += EPHEMERAL_PUB_LEN; ++ for (let i = 0; i < x25519Count; i++) { ++ need(WRAPPED_KEY_LEN, `x25519 stanza ${i}`); ++ x25519Wrapped.push(Buffer.from(bytes.subarray(off, off + WRAPPED_KEY_LEN))); off += WRAPPED_KEY_LEN; ++ } ++ } ++ let passphrase = null; ++ if (passphraseCount === 1) { ++ need(PASSPHRASE_SALT_LEN + 4 + WRAPPED_KEY_LEN, 'passphrase stanza'); ++ const salt = Buffer.from(bytes.subarray(off, off + PASSPHRASE_SALT_LEN)); off += PASSPHRASE_SALT_LEN; ++ const iterations = bytes.readUInt32BE(off); off += 4; ++ const wrapped = Buffer.from(bytes.subarray(off, off + WRAPPED_KEY_LEN)); off += WRAPPED_KEY_LEN; ++ passphrase = { salt, iterations, wrapped }; ++ } ++ const headerBytes = Buffer.from(bytes.subarray(0, off)); ++ need(2, 'ciphertextLen'); ++ const ciphertextLen = bytes.readUInt16BE(off); off += 2; ++ if (off + ciphertextLen !== bytes.length) { ++ throw new Error(`ciphertextLen ${ciphertextLen} does not match trailing bytes ${bytes.length - off}`); ++ } ++ const ciphertext = Buffer.from(bytes.subarray(off, off + ciphertextLen)); ++ ++ return { ++ version, ++ chainHeadHash, ++ chainPubkeyFp, ++ seq, ++ timestamp, ++ keyCommit, ++ ephemeralPub, ++ x25519Wrapped, ++ passphrase, ++ headerBytes, ++ ciphertext, ++ manifestLen: bytes.length, ++ }; ++} ++ + export { + VERSION, ++ VERSION_SEALED, + HEADER_FIXED_LEN, + SPINE_LEN_TAG_BYTES, + SIG_LEN, + QR_V40_BINARY_ECL_CAPACITY, + USEFUL_PAYLOAD_BUDGET, ++ KEY_COMMIT_LEN, ++ EPHEMERAL_PUB_LEN, ++ WRAPPED_KEY_LEN, ++ PASSPHRASE_SALT_LEN, ++ SEALED_FIXED_LEN, + encodeManifest, + decodeManifest, ++ encodeSealedHeader, ++ encodeSealedManifest, ++ decodeSealedManifest, + }; +diff --git a/src/publish/pack.js b/src/publish/pack.js +--- a/src/publish/pack.js ++++ b/src/publish/pack.js +@@ -19,6 +19,10 @@ + * seq, and timestamp. bin/pap-export.js does the chain-state wiring; this + * module stays testable with synthetic keys. + * ++ * Sealed mode (v2, WO-CARTRIDGE-SEAL 2026-10-26): pack() with inputs.seal ++ * encrypts, then signs. See the sealed section below and docs/PAP-SEALED.md. ++ * Without inputs.seal, pack() and unpack() are the v1 code, byte for byte. ++ * + * Ported from projects/spinoff/pap-prototype/src/pack.js (WO-PAP-01, + * built 2026-08-07, 13/13 tests green) with a CJS-to-ESM conversion only. + */ +@@ -28,9 +32,27 @@ import zlib from 'node:zlib'; + import { + encodeManifest, + decodeManifest, ++ encodeSealedHeader, ++ encodeSealedManifest, ++ decodeSealedManifest, + SIG_LEN, + QR_V40_BINARY_ECL_CAPACITY, ++ HEADER_FIXED_LEN, ++ SPINE_LEN_TAG_BYTES, ++ VERSION_SEALED, + } from './manifest.js'; ++import { ++ createSealer, ++ commitmentMatches, ++ aeadOpen, ++ openBody, ++ generateRecipientKeypair, ++ parseRecipientPrivateKey, ++ x25519Unwrap, ++ passphraseKek, ++ innerMessage, ++ outerMessage, ++} from './seal.js'; + + const BROTLI_QUALITY_MAX = 11; + +@@ -49,6 +71,7 @@ const BROTLI_QUALITY_MAX = 11; + * compressionRatio: number }} + */ + function pack(inputs) { ++ if (inputs.seal) return packSealed(inputs); + if (typeof inputs.spine !== 'string' || inputs.spine.length === 0) { + throw new Error('spine must be a non-empty string'); + } +@@ -109,26 +132,25 @@ function pack(inputs) { + * confirm the bundle was signed by a specific known key rather than + * trusting the manifest's own claim. + * @returns {{ manifest: Object, spine: string, spineRawLen: number, +- * spineCompressedLen: number }} ++ * spineCompressedLen: number, trust: string }} ++ * trust is "self-consistent" when the key came from the bundle itself, ++ * "verified-against-supplied-key" when the caller passed verifyKey (which ++ * must equal the embedded fingerprint, else PAP_SIGNER_MISMATCH). + */ + function unpack(inputs) { + if (!Buffer.isBuffer(inputs.bundle)) throw new TypeError('bundle must be a Buffer'); + if (inputs.bundle.length < SIG_LEN + 1) { + throw new Error(`bundle too short: ${inputs.bundle.length} bytes`); + } ++ if (inputs.bundle[0] === VERSION_SEALED) return unpackSealed(inputs); + const manifestBytes = inputs.bundle.subarray(0, inputs.bundle.length - SIG_LEN); + const signature = inputs.bundle.subarray(inputs.bundle.length - SIG_LEN); + const manifest = decodeManifest(Buffer.from(manifestBytes)); + +- const verifyKey = inputs.verifyKey || rawEd25519PublicKeyToKeyObject(manifest.chainPubkeyFp); +- const ok = crypto.verify(null, manifestBytes, verifyKey, signature); +- if (!ok) { +- const err = new Error('signature verification failed: bundle has been altered or was signed by a different key'); +- err.code = 'PAP_SIGNATURE_INVALID'; +- throw err; +- } ++ const { verifyKey, trust } = resolveVerifier(inputs.verifyKey, manifest.chainPubkeyFp); ++ if (!crypto.verify(null, manifestBytes, verifyKey, signature)) throw signatureInvalid(); + +- const spineRaw = zlib.brotliDecompressSync(manifest.spineCompressed); ++ const spineRaw = boundedBrotli(manifest.spineCompressed); + const spine = spineRaw.toString('utf8'); + + return { +@@ -136,6 +158,7 @@ function unpack(inputs) { + spine, + spineRawLen: spineRaw.length, + spineCompressedLen: manifest.spineCompressed.length, ++ trust, + }; + } + +@@ -149,6 +172,314 @@ function generateKeypair() { + return { publicKey, privateKey, publicKeyRaw }; + } + ++// ---------- sealed (v2) ---------- ++// ++// Encrypt-then-sign (WO-CARTRIDGE-SEAL, spec docs/PAP-SEALED.md). The outer ++// Ed25519 signature covers the ciphertext manifest, so anyone can check that ++// the bundle is unaltered and self-consistent with the key fingerprint it ++// carries, without a key. Whether that fingerprint is the author you mean is a ++// comparison the reader makes against a fingerprint they already trust. Inside ++// the ciphertext, a second signature by the same key binds the plaintext to ++// the exact header it was sealed under, so a key holder cannot be handed a ++// re-encrypted or re-addressed swap. A public key commitment makes every ++// recipient read the same plaintext. ++ ++// Cap on a decompressed spine, v1 and v2. A bundle is at most 2,953 B, but ++// brotli expands tiny inputs enormously, and the decompression runs before the ++// inner signature can be checked. Exceeding it is PAP_SPINE_TOO_LARGE. ++const MAX_SPINE_RAW_BYTES = 4 * 1024 * 1024; ++ ++const TRUST_SELF_CONSISTENT = 'self-consistent'; ++const TRUST_SUPPLIED_KEY = 'verified-against-supplied-key'; ++ ++function papError(code, message) { ++ const err = new Error(message); ++ err.code = code; ++ return err; ++} ++ ++/** brotli with an output cap and PAP error codes. Never a raw zlib error. */ ++function boundedBrotli(compressed) { ++ try { ++ return zlib.brotliDecompressSync(compressed, { maxOutputLength: MAX_SPINE_RAW_BYTES }); ++ } catch (e) { ++ if (e && e.code === 'ERR_BUFFER_TOO_LARGE') { ++ throw papError('PAP_SPINE_TOO_LARGE', ++ `spine decompresses past the ${MAX_SPINE_RAW_BYTES}-byte cap; refusing to expand it`); ++ } ++ throw papError('PAP_DECOMPRESS_FAILED', `spine is not valid brotli (${(e && (e.code || e.message)) || 'unknown error'})`); ++ } ++} ++ ++/** ++ * Pick the key the outer signature is checked under, and say on what basis. ++ * No supplied key: the bundle's own embedded fingerprint, which makes the ++ * result "self-consistent" and nothing more. A supplied key must BE that ++ * fingerprint (raw-key equality), else PAP_SIGNER_MISMATCH before any ++ * signature work; then the result is "verified against the supplied key". ++ */ ++function resolveVerifier(supplied, chainPubkeyFp) { ++ if (!supplied) { ++ return { verifyKey: rawEd25519PublicKeyToKeyObject(chainPubkeyFp), trust: TRUST_SELF_CONSISTENT }; ++ } ++ let pub = null; ++ let raw = null; ++ try { ++ pub = supplied.type === 'private' ? crypto.createPublicKey(supplied) : supplied; ++ if (pub.asymmetricKeyType === 'ed25519') raw = pub.export({ format: 'der', type: 'spki' }).subarray(-32); ++ } catch { ++ raw = null; ++ } ++ if (raw === null || raw.length !== chainPubkeyFp.length || !crypto.timingSafeEqual(raw, chainPubkeyFp)) { ++ throw papError('PAP_SIGNER_MISMATCH', ++ 'the supplied verify key is not the key this bundle carries (its embedded fingerprint differs): ' + ++ 'either this is not the signer you expected, or the bundle is not what it claims'); ++ } ++ return { verifyKey: pub, trust: TRUST_SUPPLIED_KEY }; ++} ++ ++function signatureInvalid() { ++ return papError('PAP_SIGNATURE_INVALID', ++ 'signature verification failed: the bundle does not verify under the key whose fingerprint it carries ' + ++ '(it was altered after signing, or it was not signed by that key)'); ++} ++ ++/** ++ * pack() with inputs.seal set: ++ * @param {Object} inputs.seal ++ * @param {Array} [inputs.seal.to] - X25519 recipient public ++ * keys (32-byte Buffer, 64 hex chars, or SPKI PEM) ++ * @param {string} [inputs.seal.passphrase] - optional passphrase recipient ++ * @param {number} [inputs.seal.iterations] - PBKDF2 iterations (default ++ * 600000; below 100000 is refused with PAP_KDF_TOO_WEAK) ++ */ ++function packSealed(inputs) { ++ if (typeof inputs.spine !== 'string' || inputs.spine.length === 0) { ++ throw new Error('spine must be a non-empty string'); ++ } ++ const seal = inputs.seal; ++ const sealer = createSealer({ to: seal.to || [], passphrase: seal.passphrase, iterations: seal.iterations }); ++ const recipientCount = sealer.x25519Wrapped.length; ++ const hasPassphrase = sealer.passphrase !== null; ++ ++ const spineRaw = Buffer.from(inputs.spine, 'utf8'); ++ if (spineRaw.length > MAX_SPINE_RAW_BYTES) { ++ throw papError('PAP_SPINE_TOO_LARGE', ++ `spine is ${spineRaw.length} bytes; a sealed bundle's reader refuses anything over ${MAX_SPINE_RAW_BYTES}`); ++ } ++ const spineCompressed = brotliCompressMax(spineRaw); ++ const memoirUrlBytes = Buffer.from(inputs.memoirUrl || '', 'utf8'); ++ if (memoirUrlBytes.length > 0xFFFF) { ++ throw new Error(`memoirUrl too long: ${memoirUrlBytes.length} bytes, max 65535`); ++ } ++ ++ const headerBytes = encodeSealedHeader({ ++ chainHeadHash: inputs.chainHeadHash, ++ chainPubkeyFp: inputs.chainPubkeyFp, ++ seq: inputs.seq, ++ timestamp: inputs.timestamp, ++ keyCommit: sealer.keyCommit, ++ ephemeralPub: sealer.ephemeralPub, ++ x25519Wrapped: sealer.x25519Wrapped, ++ passphrase: sealer.passphrase, ++ }); ++ ++ const innerSig = crypto.sign(null, innerMessage(headerBytes, spineRaw, memoirUrlBytes), inputs.signingKey); ++ if (innerSig.length !== SIG_LEN) { ++ throw new Error(`unexpected Ed25519 signature length: ${innerSig.length}, expected ${SIG_LEN}`); ++ } ++ const memoirLen = Buffer.alloc(2); ++ memoirLen.writeUInt16BE(memoirUrlBytes.length, 0); ++ const inner = Buffer.concat([innerSig, memoirLen, memoirUrlBytes, spineCompressed]); ++ const ciphertext = sealer.encryptBody(inner, headerBytes); ++ const manifestBytes = encodeSealedManifest(headerBytes, ciphertext); ++ ++ const signature = crypto.sign(null, outerMessage(manifestBytes), inputs.signingKey); ++ if (signature.length !== SIG_LEN) { ++ throw new Error(`unexpected Ed25519 signature length: ${signature.length}, expected ${SIG_LEN}`); ++ } ++ const bundle = Buffer.concat([manifestBytes, signature]); ++ const totalLen = bundle.length; ++ const budget = QR_V40_BINARY_ECL_CAPACITY; ++ const headroom = budget - totalLen; ++ // What the same spine and memoir cost as a plaintext v1 bundle, so the ++ // price of sealing is a measured number, not an estimate. ++ const plainEquivalentLen = HEADER_FIXED_LEN + memoirUrlBytes.length + SPINE_LEN_TAG_BYTES + spineCompressed.length + SIG_LEN; ++ const sealOverhead = totalLen - plainEquivalentLen; ++ ++ if (totalLen > budget) { ++ const err = new Error( ++ budgetMessage(spineRaw.length, spineCompressed.length, manifestBytes.length, totalLen, budget) + ++ `\n of which sealing costs ${sealOverhead} bytes (${recipientCount} recipient key(s)${hasPassphrase ? ' plus a passphrase' : ''}).`, ++ ); ++ err.code = 'PAP_BUDGET_EXCEEDED'; ++ err.spineRawLen = spineRaw.length; ++ err.spineCompressedLen = spineCompressed.length; ++ err.totalLen = totalLen; ++ err.budget = budget; ++ err.sealOverhead = sealOverhead; ++ throw err; ++ } ++ ++ return { ++ bundle, ++ manifestLen: manifestBytes.length, ++ sigLen: signature.length, ++ spineRawLen: spineRaw.length, ++ spineCompressedLen: spineCompressed.length, ++ totalLen, ++ budget, ++ headroom, ++ compressionRatio: spineRaw.length / spineCompressed.length, ++ sealed: true, ++ recipients: { x25519: recipientCount, passphrase: hasPassphrase }, ++ sealOverhead, ++ }; ++} ++ ++/** ++ * Verify the outer signature and report the public fields of a v1 or v2 ++ * bundle. Never decrypts and needs no key: this is what a stranger can check. ++ * ++ * What a pass means depends on info.trust. 'self-consistent': the bundle is ++ * unaltered and verifies under the key fingerprint it carries, which proves ++ * nothing about WHO holds that key until the reader compares the fingerprint ++ * to one they already trust. 'verified-against-supplied-key': the caller's ++ * key equals the embedded fingerprint and the signature verifies under it. ++ * ++ * @param {Object} inputs ++ * @param {Buffer} inputs.bundle ++ * @param {crypto.KeyObject} [inputs.verifyKey] - as in unpack(). Must equal ++ * the bundle's embedded fingerprint, else PAP_SIGNER_MISMATCH. ++ */ ++function inspect(inputs) { ++ if (!Buffer.isBuffer(inputs.bundle)) throw new TypeError('bundle must be a Buffer'); ++ if (inputs.bundle.length < SIG_LEN + 1) { ++ throw new Error(`bundle too short: ${inputs.bundle.length} bytes`); ++ } ++ const manifestBytes = inputs.bundle.subarray(0, inputs.bundle.length - SIG_LEN); ++ const signature = inputs.bundle.subarray(inputs.bundle.length - SIG_LEN); ++ const sealed = inputs.bundle[0] === VERSION_SEALED; ++ const m = sealed ? decodeSealedManifest(Buffer.from(manifestBytes)) : decodeManifest(Buffer.from(manifestBytes)); ++ const { verifyKey, trust } = resolveVerifier(inputs.verifyKey, m.chainPubkeyFp); ++ // v2 signs a domain string and the manifest; v1 signs the bare manifest. ++ const signedBytes = sealed ? outerMessage(manifestBytes) : manifestBytes; ++ if (!crypto.verify(null, signedBytes, verifyKey, signature)) throw signatureInvalid(); ++ const info = { ++ version: m.version, ++ sealed, ++ trust, ++ chainHeadHash: m.chainHeadHash, ++ chainPubkeyFp: m.chainPubkeyFp, ++ seq: m.seq, ++ timestamp: m.timestamp, ++ bytes: inputs.bundle.length, ++ }; ++ if (sealed) { ++ info.recipients = { x25519: m.x25519Wrapped.length, passphrase: m.passphrase !== null }; ++ info.keyCommit = m.keyCommit; ++ info.ciphertextLen = m.ciphertext.length; ++ } else { ++ info.memoirUrl = m.memoirUrl; ++ } ++ return { info, manifest: m, verifyKey, trust }; ++} ++ ++/** A content key is 32 raw bytes or 64 hex chars; anything else is not a key. */ ++function parseContentKey(input) { ++ if (Buffer.isBuffer(input) && input.length === 32) return input; ++ if (typeof input === 'string' && /^[0-9a-f]{64}$/i.test(input.trim())) return Buffer.from(input.trim(), 'hex'); ++ throw papError('PAP_BAD_CONTENT_KEY', 'content key must be 32 bytes or 64 hex characters'); ++} ++ ++/** ++ * unpack() for a v2 bundle. One of recipientKey, passphrase or contentKey ++ * opens it; with none, throws PAP_SEALED_NEEDS_KEY carrying err.info (the ++ * outer signature has already verified by then). ++ */ ++function unpackSealed(inputs) { ++ const { info, manifest: m, verifyKey, trust } = inspect(inputs); ++ ++ let cek = null; ++ if (inputs.contentKey !== undefined && inputs.contentKey !== null) { ++ cek = parseContentKey(inputs.contentKey); ++ } else if (inputs.recipientKey === undefined && inputs.passphrase === undefined) { ++ const err = new Error( ++ `verified, sealed to ${describeRecipients(info.recipients)}; no key given, so the spine stays closed`, ++ ); ++ err.code = 'PAP_SEALED_NEEDS_KEY'; ++ err.info = info; ++ throw err; ++ } else { ++ if (inputs.passphrase !== undefined && m.passphrase) { ++ const kek = passphraseKek(inputs.passphrase, m.passphrase.salt, m.passphrase.iterations); ++ cek = aeadOpen(kek, m.passphrase.wrapped, null); ++ } ++ if (!cek && inputs.recipientKey !== undefined && m.ephemeralPub) { ++ const priv = parseRecipientPrivateKey(inputs.recipientKey); ++ for (const w of m.x25519Wrapped) { ++ cek = x25519Unwrap(w, priv, m.ephemeralPub); ++ if (cek) break; ++ } ++ } ++ if (!cek) { ++ const err = new Error('this key does not open the bundle: it is not one of the recipients (the outer signature did verify)'); ++ err.code = 'PAP_NOT_A_RECIPIENT'; ++ err.info = info; ++ throw err; ++ } ++ } ++ ++ if (!commitmentMatches(cek, m.keyCommit)) { ++ const err = new Error('content key does not match the bundle\'s key commitment: refusing to decrypt'); ++ err.code = 'PAP_KEY_COMMIT_MISMATCH'; ++ throw err; ++ } ++ const inner = openBody(cek, m.ciphertext, m.headerBytes); ++ if (!inner || inner.length < SIG_LEN + 2) { ++ const err = new Error('sealed body failed authentication'); ++ err.code = 'PAP_DECRYPT_FAILED'; ++ throw err; ++ } ++ const innerSig = inner.subarray(0, SIG_LEN); ++ const memoirUrlLen = inner.readUInt16BE(SIG_LEN); ++ if (SIG_LEN + 2 + memoirUrlLen > inner.length) { ++ const err = new Error('sealed body is malformed: memoir URL runs past the end'); ++ err.code = 'PAP_DECRYPT_FAILED'; ++ throw err; ++ } ++ const memoirUrlBytes = inner.subarray(SIG_LEN + 2, SIG_LEN + 2 + memoirUrlLen); ++ const spineCompressed = Buffer.from(inner.subarray(SIG_LEN + 2 + memoirUrlLen)); ++ const spineRaw = boundedBrotli(spineCompressed); ++ ++ if (!crypto.verify(null, innerMessage(m.headerBytes, spineRaw, memoirUrlBytes), verifyKey, innerSig)) { ++ const err = new Error( ++ 'inner binding failed: the plaintext was not signed under this header by this key ' + ++ '(re-encrypted, re-addressed, or re-signed by someone else)', ++ ); ++ err.code = 'PAP_INNER_BINDING_INVALID'; ++ throw err; ++ } ++ ++ return { ++ manifest: { ...info, memoirUrl: memoirUrlBytes.toString('utf8') }, ++ trust, ++ spine: spineRaw.toString('utf8'), ++ spineRawLen: spineRaw.length, ++ spineCompressedLen: spineCompressed.length, ++ // Anyone holding this opens the bundle, and the public commitment proves ++ // it is the right key. Publishing it is how a sealed bundle is revealed. ++ contentKey: cek, ++ }; ++} ++ ++function describeRecipients(r) { ++ const parts = []; ++ if (r.x25519 > 0) parts.push(`${r.x25519} recipient key${r.x25519 === 1 ? '' : 's'}`); ++ if (r.passphrase) parts.push('a passphrase'); ++ return parts.join(' and '); ++} ++ + // ---------- internal ---------- + + function brotliCompressMax(buf) { +@@ -187,5 +518,8 @@ function budgetMessage(spineRaw, spineCompressed, manifestLen, totalLen, budget) + export { + pack, + unpack, ++ inspect, + generateKeypair, ++ generateRecipientKeypair, ++ MAX_SPINE_RAW_BYTES, + }; +diff --git a/src/publish/seal.js b/src/publish/seal.js +new file mode 100644 +--- /dev/null ++++ b/src/publish/seal.js +@@ -0,0 +1,362 @@ ++/** ++ * PAP seal: the crypto under a sealed (v2) bundle. No layout here; that is ++ * manifest.js. No signing of the outer bundle here; that is pack.js. ++ * ++ * Construction (spec: docs/PAP-SEALED.md), published primitives only: ++ * - content key (CEK): 32 random bytes, fresh per bundle ++ * - key commitment: SHA-256("lotor/pap/v2/key-commit" 0x00 || CEK), public. ++ * AES-GCM is not key-committing, so a separate commitment makes every ++ * recipient check the CEK it unwrapped against one public value. It is ++ * defence in depth (forging two keys that open one GCM body to different ++ * plaintexts is far from free against random keys, but it is not a ++ * property GCM promises) plus the verifiable reveal: a published CEK is ++ * checked against this value by anyone. ++ * - body: AES-256-GCM under HKDF-SHA256(CEK, info "lotor/pap/v2/body"), ++ * 12-byte zero nonce, header bytes as associated data ++ * - X25519 recipient: one ephemeral X25519 key per bundle; per recipient ++ * KEK = HKDF-SHA256(X25519(eph, recipient), salt eph || recipient, ++ * info "lotor/pap/v2/x25519-wrap"); wrapped = AES-256-GCM(KEK, CEK), 48 B ++ * - passphrase: KEK = PBKDF2-HMAC-SHA256(passphrase, salt16, iterations); ++ * wrapped = AES-256-GCM(KEK, CEK), 48 B ++ * ++ * Why a zero nonce is safe: every key it is used with is used exactly once. ++ * The body key comes from a fresh CEK, each X25519 KEK from a fresh ephemeral ++ * key, each passphrase KEK from a fresh salt. Same reasoning, same shape as ++ * age's file-key wrapping. AES-256-GCM (not XChaCha20-Poly1305) because it is ++ * in both node:crypto and WebCrypto, so the offline reader needs no AEAD ++ * library. ++ * ++ * That reasoning only holds if no caller can pick the key. So nothing here is ++ * exported that encrypts under a caller-supplied key. The only way in is ++ * createSealer(): it makes the CEK, the ephemeral key and every KEK inside its ++ * own closure, hands back the public stanzas, and its encryptBody() runs once. ++ * A second call throws. The decrypt side (aeadOpen, openBody, x25519Unwrap) is ++ * exported because opening under a key you hold is not the hazard. ++ * ++ * WO-CARTRIDGE-SEAL, 2026-10-26; amended the same day after the wringer review. ++ * Queued for a signing sitting: src/publish is core (src/grant/core-paths.js ++ * CORE_DIRS). ++ */ ++ ++import crypto from 'node:crypto'; ++import { PASSPHRASE_SALT_LEN } from './manifest.js'; ++ ++const CEK_LEN = 32; ++const GCM_TAG_LEN = 16; ++const ZERO_NONCE = Buffer.alloc(12); ++const NUL = Buffer.from([0]); ++ ++const DOMAIN_KEY_COMMIT = Buffer.from('lotor/pap/v2/key-commit', 'utf8'); ++const DOMAIN_BODY = 'lotor/pap/v2/body'; ++const DOMAIN_X25519_WRAP = 'lotor/pap/v2/x25519-wrap'; ++const DOMAIN_INNER = Buffer.from('lotor/pap/v2/inner', 'utf8'); ++const DOMAIN_OUTER = Buffer.from('lotor/pap/v2/outer', 'utf8'); ++ ++const DEFAULT_PBKDF2_ITERATIONS = 600000; ++// Seal-time floor. PBKDF2 is not memory-hard (the price of WebCrypto support, ++// KNOWN-LIMITS 45); below this count a stolen QR is cheap to attack. The count ++// is carried in the bundle, so older or other writers are still readable. ++const MIN_PBKDF2_ITERATIONS = 100000; ++const MAX_PBKDF2_ITERATIONS = 10000000; ++ ++// SPKI / PKCS8 DER prefixes for X25519, per RFC 8410 (OID 1.3.101.110). ++const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex'); ++const X25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex'); ++ ++function papError(code, message) { ++ const err = new Error(message); ++ err.code = code; ++ return err; ++} ++ ++/** Re-throw anything a key parser raised as a coded PAP_BAD_KEY. */ ++function badKey(e, what) { ++ if (e && e.code === 'PAP_BAD_KEY') return e; ++ return papError('PAP_BAD_KEY', `${what}: ${e && e.message ? e.message : String(e)}`); ++} ++ ++function sha256(...parts) { ++ const h = crypto.createHash('sha256'); ++ for (const p of parts) h.update(p); ++ return h.digest(); ++} ++ ++function keyCommitment(cek) { ++ return sha256(DOMAIN_KEY_COMMIT, NUL, cek); ++} ++ ++function commitmentMatches(cek, commit) { ++ const mine = keyCommitment(cek); ++ return commit.length === mine.length && crypto.timingSafeEqual(mine, commit); ++} ++ ++function bodyKey(cek) { ++ return Buffer.from(crypto.hkdfSync('sha256', cek, Buffer.alloc(0), DOMAIN_BODY, 32)); ++} ++ ++/** ++ * Module-private on purpose. AES-GCM with the fixed zero nonce is safe only ++ * for a key that is used once, and this function cannot know that. ++ */ ++function aeadSeal(key, plaintext, aad) { ++ const c = crypto.createCipheriv('aes-256-gcm', key, ZERO_NONCE); ++ if (aad) c.setAAD(aad); ++ const body = Buffer.concat([c.update(plaintext), c.final()]); ++ return Buffer.concat([body, c.getAuthTag()]); ++} ++ ++/** Returns the plaintext, or null when authentication fails. */ ++function aeadOpen(key, sealed, aad) { ++ if (sealed.length < GCM_TAG_LEN) return null; ++ const d = crypto.createDecipheriv('aes-256-gcm', key, ZERO_NONCE); ++ if (aad) d.setAAD(aad); ++ d.setAuthTag(sealed.subarray(sealed.length - GCM_TAG_LEN)); ++ try { ++ return Buffer.concat([d.update(sealed.subarray(0, sealed.length - GCM_TAG_LEN)), d.final()]); ++ } catch { ++ return null; ++ } ++} ++ ++/** Decrypt the sealed body under a content key. Null when it does not authenticate. */ ++function openBody(cek, ciphertext, headerBytes) { ++ return aeadOpen(bodyKey(cek), ciphertext, headerBytes); ++} ++ ++// ---------- X25519 ---------- ++ ++function x25519PublicFromRaw(raw32) { ++ if (!Buffer.isBuffer(raw32) || raw32.length !== 32) throw papError('PAP_BAD_KEY', 'X25519 public key must be 32 bytes'); ++ try { ++ return crypto.createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, raw32]), format: 'der', type: 'spki' }); ++ } catch (e) { ++ throw badKey(e, 'X25519 public key rejected'); ++ } ++} ++ ++function x25519PrivateFromRaw(raw32) { ++ if (!Buffer.isBuffer(raw32) || raw32.length !== 32) throw papError('PAP_BAD_KEY', 'X25519 private key must be 32 bytes'); ++ try { ++ return crypto.createPrivateKey({ key: Buffer.concat([X25519_PKCS8_PREFIX, raw32]), format: 'der', type: 'pkcs8' }); ++ } catch (e) { ++ throw badKey(e, 'X25519 private key rejected'); ++ } ++} ++ ++function x25519RawPublic(keyObj) { ++ const pub = keyObj.type === 'private' ? crypto.createPublicKey(keyObj) : keyObj; ++ return pub.export({ format: 'der', type: 'spki' }).subarray(-32); ++} ++ ++function generateRecipientKeypair() { ++ const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519'); ++ return { ++ publicKey, ++ privateKey, ++ publicKeyRaw: x25519RawPublic(publicKey), ++ privateKeyRaw: privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(-32), ++ }; ++} ++ ++/** Accepts a 32-byte Buffer, 64 hex chars, or an SPKI PEM string. */ ++function parseRecipientPublicKey(input) { ++ try { ++ if (Buffer.isBuffer(input)) return x25519RawPublic(x25519PublicFromRaw(input)); ++ const s = String(input).trim(); ++ if (/^[0-9a-f]{64}$/i.test(s)) return x25519RawPublic(x25519PublicFromRaw(Buffer.from(s, 'hex'))); ++ const k = crypto.createPublicKey(s); ++ if (k.asymmetricKeyType !== 'x25519') throw papError('PAP_BAD_KEY', `recipient key is ${k.asymmetricKeyType}, expected x25519`); ++ return x25519RawPublic(k); ++ } catch (e) { ++ throw badKey(e, 'recipient public key is not usable'); ++ } ++} ++ ++/** Accepts a KeyObject, a 32-byte Buffer, 64 hex chars, or a PKCS8 PEM string. */ ++function parseRecipientPrivateKey(input) { ++ try { ++ let k; ++ if (input && typeof input === 'object' && input.type === 'private') k = input; ++ else if (Buffer.isBuffer(input)) k = x25519PrivateFromRaw(input); ++ else { ++ const s = String(input).trim(); ++ k = /^[0-9a-f]{64}$/i.test(s) ? x25519PrivateFromRaw(Buffer.from(s, 'hex')) : crypto.createPrivateKey(s); ++ } ++ if (k.asymmetricKeyType !== 'x25519') throw papError('PAP_BAD_KEY', `recipient key is ${k.asymmetricKeyType}, expected x25519`); ++ return k; ++ } catch (e) { ++ throw badKey(e, 'recipient private key is not usable'); ++ } ++} ++ ++/** ++ * Derive the key-encryption key for one stanza. Both keys are validated as ++ * X25519 first, so a failure of the Diffie-Hellman itself can only be a ++ * degenerate (low-order) peer key: OpenSSL refuses to return an all-zero ++ * shared secret, and if it ever did, the explicit check below catches it. That ++ * one case is PAP_X25519_LOW_ORDER; every other failure is a PAP_BAD_KEY. ++ */ ++function x25519Kek(privateKey, peerRaw, ephPubRaw, recipientPubRaw) { ++ if (!privateKey || privateKey.type !== 'private' || privateKey.asymmetricKeyType !== 'x25519') { ++ throw papError('PAP_BAD_KEY', 'recipient key is not an x25519 private key, expected x25519'); ++ } ++ const peer = x25519PublicFromRaw(peerRaw); ++ let shared; ++ try { ++ shared = crypto.diffieHellman({ privateKey, publicKey: peer }); ++ } catch { ++ throw papError('PAP_X25519_LOW_ORDER', 'X25519 refused the peer key (low-order point)'); ++ } ++ // An all-zero shared secret means a low-order peer key. Refuse it rather ++ // than derive a key an attacker can predict. ++ if (shared.every((b) => b === 0)) throw papError('PAP_X25519_LOW_ORDER', 'X25519 produced an all-zero shared secret'); ++ return Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.concat([ephPubRaw, recipientPubRaw]), DOMAIN_X25519_WRAP, 32)); ++} ++ ++/** ++ * Returns the CEK, or null when this stanza is not for this key. Null covers ++ * exactly two things: the stanza does not authenticate under this key, and a ++ * low-order peer key (refused, so it is not a stanza for anyone). Any other ++ * failure is a coded PAP_BAD_KEY and is not swallowed. ++ */ ++function x25519Unwrap(wrapped, recipientPrivateKey, ephPubRaw) { ++ if (!recipientPrivateKey || recipientPrivateKey.type !== 'private' || recipientPrivateKey.asymmetricKeyType !== 'x25519') { ++ throw papError('PAP_BAD_KEY', 'recipient key is not an x25519 private key, expected x25519'); ++ } ++ const recipientPubRaw = x25519RawPublic(recipientPrivateKey); ++ let kek; ++ try { ++ kek = x25519Kek(recipientPrivateKey, ephPubRaw, ephPubRaw, recipientPubRaw); ++ } catch (e) { ++ if (e && e.code === 'PAP_X25519_LOW_ORDER') return null; ++ throw badKey(e, 'cannot unwrap with this key'); ++ } ++ return aeadOpen(kek, wrapped, null); ++} ++ ++// ---------- passphrase ---------- ++ ++function passphraseKek(passphrase, salt, iterations) { ++ if (typeof passphrase !== 'string' || passphrase.length === 0) throw new Error('passphrase must be a non-empty string'); ++ if (!Number.isInteger(iterations) || iterations < 1 || iterations > MAX_PBKDF2_ITERATIONS) { ++ throw papError('PAP_KDF_BAD_PARAMS', `PBKDF2 iterations must be 1..${MAX_PBKDF2_ITERATIONS}, got ${iterations}`); ++ } ++ return crypto.pbkdf2Sync(Buffer.from(passphrase, 'utf8'), salt, iterations, 32, 'sha256'); ++} ++ ++// ---------- sealing (the only way in) ---------- ++ ++/** ++ * Start one seal. Returns the public stanzas and a body encryptor that runs ++ * exactly once. The content key, the ephemeral private key and every KEK live ++ * and die in this closure. ++ * ++ * @param {Object} opts ++ * @param {Array} [opts.to] - X25519 recipient public keys ++ * @param {string} [opts.passphrase] - optional passphrase recipient ++ * @param {number} [opts.iterations] - PBKDF2 iterations, default 600000, ++ * floor 100000 (PAP_KDF_TOO_WEAK below it) ++ * @returns {{ keyCommit: Buffer, ephemeralPub: Buffer|null, ++ * x25519Wrapped: Buffer[], passphrase: {salt, iterations, wrapped}|null, ++ * encryptBody: (plaintext: Buffer, headerBytes: Buffer) => Buffer }} ++ */ ++function createSealer({ to = [], passphrase, iterations } = {}) { ++ const recipients = to.map(parseRecipientPublicKey); ++ const hasPassphrase = passphrase !== undefined && passphrase !== null; ++ if (recipients.length === 0 && !hasPassphrase) { ++ throw new Error('seal needs at least one recipient (seal.to) or a passphrase'); ++ } ++ if (new Set(recipients.map((k) => k.toString('hex'))).size !== recipients.length) { ++ throw new Error('seal.to lists the same recipient twice'); ++ } ++ let rounds = null; ++ if (hasPassphrase) { ++ rounds = iterations === undefined ? DEFAULT_PBKDF2_ITERATIONS : iterations; ++ if (Number.isInteger(rounds) && rounds < MIN_PBKDF2_ITERATIONS) { ++ throw papError('PAP_KDF_TOO_WEAK', ++ `PBKDF2 iterations ${rounds} is below the floor of ${MIN_PBKDF2_ITERATIONS}; the count is public in the bundle and a QR can be attacked offline`); ++ } ++ } ++ ++ const cek = crypto.randomBytes(CEK_LEN); ++ let ephemeralPub = null; ++ let x25519Wrapped = []; ++ if (recipients.length > 0) { ++ const eph = crypto.generateKeyPairSync('x25519'); ++ ephemeralPub = x25519RawPublic(eph.publicKey); ++ x25519Wrapped = recipients.map((r, i) => { ++ try { ++ return aeadSeal(x25519Kek(eph.privateKey, r, ephemeralPub, r), cek, null); ++ } catch (e) { ++ if (e && e.code === 'PAP_X25519_LOW_ORDER') throw papError('PAP_BAD_KEY', `recipient key ${i} is a low-order point and cannot be sealed to`); ++ throw e; ++ } ++ }); ++ } ++ let passphraseStanza = null; ++ if (hasPassphrase) { ++ const salt = crypto.randomBytes(PASSPHRASE_SALT_LEN); ++ const kek = passphraseKek(passphrase, salt, rounds); ++ passphraseStanza = { salt, iterations: rounds, wrapped: aeadSeal(kek, cek, null) }; ++ } ++ ++ const keyCommit = keyCommitment(cek); ++ const bodyEncryptionKey = bodyKey(cek); ++ let used = false; ++ return Object.freeze({ ++ keyCommit, ++ ephemeralPub, ++ x25519Wrapped, ++ passphrase: passphraseStanza, ++ encryptBody(plaintext, headerBytes) { ++ if (used) { ++ throw papError('PAP_SEALER_REUSED', 'this sealer already encrypted a body; the zero nonce is safe only once per key'); ++ } ++ used = true; ++ return aeadSeal(bodyEncryptionKey, plaintext, headerBytes); ++ }, ++ }); ++} ++ ++// ---------- signed messages ---------- ++ ++/** ++ * The bytes the inner signature covers. Signing these with the chain key ++ * binds the plaintext spine and memoir URL to the exact header they were ++ * sealed under: signer, chain head, seq, timestamp, key commitment, and every ++ * recipient stanza. Re-encrypting or re-addressing the same plaintext changes ++ * the header, so the inner signature no longer verifies. ++ */ ++function innerMessage(headerBytes, spineRaw, memoirUrlBytes) { ++ return Buffer.concat([DOMAIN_INNER, NUL, sha256(headerBytes), sha256(spineRaw), sha256(memoirUrlBytes)]); ++} ++ ++/** ++ * The bytes the v2 OUTER signature covers: a domain string, a NUL, then the ++ * whole manifest. v1 signs the bare manifest and stays that way; the domain ++ * string keeps a v2 signature from ever being valid as anything else, and is ++ * free now and impossible to add once the format is frozen. ++ */ ++function outerMessage(manifestBytes) { ++ return Buffer.concat([DOMAIN_OUTER, NUL, manifestBytes]); ++} ++ ++export { ++ CEK_LEN, ++ GCM_TAG_LEN, ++ DEFAULT_PBKDF2_ITERATIONS, ++ MIN_PBKDF2_ITERATIONS, ++ MAX_PBKDF2_ITERATIONS, ++ createSealer, ++ commitmentMatches, ++ aeadOpen, ++ openBody, ++ x25519RawPublic, ++ generateRecipientKeypair, ++ parseRecipientPublicKey, ++ parseRecipientPrivateKey, ++ x25519Unwrap, ++ passphraseKek, ++ innerMessage, ++ outerMessage, ++}; diff --git a/proposals/pap-sealed/README.md b/proposals/pap-sealed/README.md new file mode 100644 index 0000000..30b51d2 --- /dev/null +++ b/proposals/pap-sealed/README.md @@ -0,0 +1,46 @@ +# Sealed PAP bundles: queued core patch (WO-CARTRIDGE-SEAL part A) + +`src/publish` and every script in `bin/` are core (`src/grant/core-paths.js` +CORE_DIRS; `src/publish` was classified core at the 2026-08-09 sitting). So sealed +mode is a patch, not a commit, and lands one signature per target. + +## Files + +- `../make-pap-sealed-patch.mjs` generates `../pap-sealed.patch`. It reads the + current core files read only, refuses if any moved from the base it was written + against, and runs `git diff --no-index` over temp old/new trees. The patch is + never typed by hand (KNOWN-LIMITS 80). +- `src.publish.seal.js`, `bin.pap-export.js`, `bin.pap-boot.js`, `bin.pap-keygen.js`: + proposed whole files. Flat names, because a path containing a core directory is + gated for any write, in any checkout. +- `manifest.v2-block.txt`, `pack.sealed-block.txt`: blocks the generator splices + into `src/publish/manifest.js` and `src/publish/pack.js`. The rest of those files + is untouched, and v1 bundles stay byte for byte. + +## At the sitting (from the live install, after the PR merges) + +1. `node proposals/make-pap-sealed-patch.mjs` (ordinary): regenerate; must report + 6 files and the same byte count as the committed patch. `git diff --exit-code + proposals/pap-sealed.patch` must be clean. +2. One signed `git apply` per target, each named on the command line so the gate + sees it (KNOWN-LIMITS 82): + `git apply --include=src/publish/seal.js proposals/pap-sealed.patch`, then + `manifest.js`, `pack.js`, `bin/pap-export.js`, `bin/pap-boot.js`, + `bin/pap-keygen.js`. Six signatures. +3. `npm run limits-pin -- --stamp` (src/ moved), then `npm test`. Expected: the 29 + sealed tests run instead of skipping, the 3 rehearsal tests skip (LANDED), 0 fail. + (The 5 docs tests in `test/pap-sealed-docs.test.js` run before and after.) + +## Review amendments (wringer, 2026-10-26) + +Two things the sitting should know changed after the first draft of this patch: + +- The outer signature of a v2 bundle covers `"lotor/pap/v2/outer" 0x00 || manifest`. + Vectors made before the amendment do not verify; the committed ones are the new ones. +- `seal.js` exports no function that encrypts under a caller-supplied key. Low-level + sealing for tests and vector generation lives in + `test-data/pap-vectors/seal-primitives.mjs` (test only, no safety limits, never + imported from `src/` or `bin/`). +4. Optional live check: `node bin/pap-keygen.js --out /me`, export a sealed + bundle to `/me.pub`, `pap-boot` it with no key (exit 5) and with the key + (exit 0). diff --git a/proposals/pap-sealed/bin.pap-boot.js b/proposals/pap-sealed/bin.pap-boot.js new file mode 100644 index 0000000..5464a16 --- /dev/null +++ b/proposals/pap-sealed/bin.pap-boot.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * pap-boot: decode a signed bundle back into a spine. + * + * Usage: + * node bin/pap-boot.js --bundle [--verify-key ] + * node bin/pap-boot.js --bundle --recipient-key + * node bin/pap-boot.js --bundle --passphrase-env + * node bin/pap-boot.js --bundle --content-key + * + * Reads bundle bytes from --bundle (or stdin if the path is `-`), + * verifies the signature, decompresses the spine, and writes it to stdout. + * + * What "verified" means is printed as a trust line. With no --verify-key the + * signature is checked against the pubkey the bundle itself carries, so the + * result is "self-consistent": the bundle is unaltered and agrees with its own + * fingerprint. That proves who signed it only if you compare the printed + * fingerprint with one you already trust. With --verify-key the key must BE + * the bundle's embedded fingerprint, else exit 4 (signer mismatch), and the + * trust line reads "verified-against-supplied-key". + * + * A sealed (v2) bundle verifies without a key: with no key flag, pap-boot + * prints "verified, sealed to N" and exits 5 with nothing on stdout. A + * recipient key, the passphrase, or the revealed content key opens it. + * --print-content-key writes the content key to stderr after opening, so a + * recipient can reveal the bundle to everyone later; the bundle's public key + * commitment lets anyone check that a revealed key is the right one. + * + * Exit codes: + * 0 on success + * 1 on I/O error or unexpected failure + * 2 on bad arguments (including a malformed recipient or content key) + * 4 on signature verification failure, or a --verify-key that is not the + * bundle's embedded signer (limit of the record itself) + * 5 sealed bundle verified, but no key given to open it + * 6 sealed bundle verified, but the key does not open it + * 7 bundle failed its key commitment, decrypt or inner binding check, or + * its spine would expand past the cap or is not valid brotli + * + * A separate decoder-from-image tool is out of scope for v1 (see + * src/publish/qr.js). The boot spec (BOOT-SPEC.md in the WO-PAP-01 + * prototype directory) describes the byte-mode read a reader must do to + * get from a QR image to the bundle bytes this command consumes. + * + * Verification note (KNOWN-LIMITS 46): a verified signature proves the + * decoded bytes are the bytes the key signed. It says nothing about + * whether the spine boots a functional agent or whether the identity + * described matches who the operator meant to publish. + * + * Ported from projects/spinoff/pap-prototype/cli/pap-boot.js + * (WO-PAP-01) with a CJS-to-ESM conversion only. + */ + +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import { unpack } from '../src/publish/pack.js'; + +const SEALED_EXIT = { + PAP_SEALED_NEEDS_KEY: 5, + PAP_NOT_A_RECIPIENT: 6, + PAP_KEY_COMMIT_MISMATCH: 7, + PAP_DECRYPT_FAILED: 7, + PAP_INNER_BINDING_INVALID: 7, + PAP_SPINE_TOO_LARGE: 7, + PAP_DECOMPRESS_FAILED: 7, + PAP_KDF_BAD_PARAMS: 7, + PAP_BAD_KEY: 2, + PAP_BAD_CONTENT_KEY: 2, +}; + +function trustLine(trust) { + if (trust === 'verified-against-supplied-key') { + return ' trust verified-against-supplied-key (matches the key given with --verify-key)\n'; + } + return ' trust self-consistent (verified against the fingerprint the bundle carries; ' + + 'compare the fingerprint with one you trust before relying on who signed it)\n'; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.bundle) die('missing --bundle (or --bundle - for stdin)'); + + const bundle = args.bundle === '-' + ? fs.readFileSync(0) + : fs.readFileSync(args.bundle); + + const verifyKey = args.verifyKey + ? crypto.createPublicKey({ + key: fs.readFileSync(args.verifyKey), + format: 'pem', + }) + : undefined; + + const opener = {}; + if (args.recipientKey) { + opener.recipientKey = /^[0-9a-f]{64}$/i.test(args.recipientKey) + ? args.recipientKey + : fs.readFileSync(args.recipientKey, 'utf8').trim(); + } + if (args.passphraseEnv) { + const p = process.env[args.passphraseEnv]; + if (!p) die(`--passphrase-env ${args.passphraseEnv}: that environment variable is empty or unset`); + opener.passphrase = p; + } + if (args.contentKey) { + if (!/^[0-9a-f]{64}$/i.test(args.contentKey)) die('--content-key must be 64 hex characters'); + opener.contentKey = args.contentKey; + } + + try { + const { manifest, spine, spineRawLen, spineCompressedLen, contentKey, trust } = unpack({ bundle, verifyKey, ...opener }); + process.stdout.write(spine); + process.stderr.write( + `pap-boot ok\n` + + ` version ${manifest.version}\n` + + (manifest.sealed ? ` sealed to ${describe(manifest.recipients)}, opened\n` : '') + + ` seq ${manifest.seq}\n` + + ` timestamp ${manifest.timestamp} (${new Date(manifest.timestamp).toISOString()})\n` + + ` chain head ${manifest.chainHeadHash.toString('hex')}\n` + + ` chain pubkey fp ${manifest.chainPubkeyFp.toString('hex')}\n` + + trustLine(trust) + + (manifest.memoirUrl ? ` memoir ${manifest.memoirUrl}\n` : '') + + ` raw ${spineRawLen} B, compressed ${spineCompressedLen} B\n` + + (args.printContentKey && contentKey ? ` content key ${contentKey.toString('hex')}\n` : ''), + ); + process.exit(0); + } catch (err) { + if (err.code === 'PAP_SIGNATURE_INVALID') { + process.stderr.write('pap-boot: ' + err.message + '\n'); + process.exit(4); + } + if (err.code === 'PAP_SIGNER_MISMATCH') { + process.stderr.write('pap-boot: signer mismatch: ' + err.message + '\n'); + process.exit(4); + } + if (err.code in SEALED_EXIT) { + const i = err.info; + process.stderr.write( + 'pap-boot: ' + err.message + '\n' + + (i + ? ` seq ${i.seq}\n` + + ` timestamp ${i.timestamp} (${new Date(i.timestamp).toISOString()})\n` + + ` chain head ${i.chainHeadHash.toString('hex')}\n` + + ` chain pubkey fp ${i.chainPubkeyFp.toString('hex')}\n` + + trustLine(i.trust) + : ''), + ); + process.exit(SEALED_EXIT[err.code]); + } + process.stderr.write('pap-boot: ' + (err && err.stack || err) + '\n'); + process.exit(1); + } +} + +function describe(r) { + const parts = []; + if (r.x25519 > 0) parts.push(`${r.x25519} recipient key(s)`); + if (r.passphrase) parts.push('a passphrase'); + return parts.join(' and '); +} + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + switch (a) { + case '--bundle': out.bundle = argv[++i]; break; + case '--verify-key': out.verifyKey = argv[++i]; break; + case '--recipient-key': out.recipientKey = argv[++i]; break; + case '--passphrase-env': out.passphraseEnv = argv[++i]; break; + case '--content-key': out.contentKey = argv[++i]; break; + case '--print-content-key': out.printContentKey = true; break; + case '-h': case '--help': out.help = true; break; + default: + if (!a.startsWith('-')) break; + die(`unknown flag: ${a}`); + } + } + return out; +} + +function die(msg) { + process.stderr.write('pap-boot: ' + msg + '\n'); + process.exit(2); +} + +main(); diff --git a/proposals/pap-sealed/bin.pap-export.js b/proposals/pap-sealed/bin.pap-export.js new file mode 100644 index 0000000..45287fa --- /dev/null +++ b/proposals/pap-sealed/bin.pap-export.js @@ -0,0 +1,257 @@ +#!/usr/bin/env node +/** + * pap-export: encode a spine into a signed QR bundle. + * + * Usage: + * node bin/pap-export.js --spine [options] --public + * + * Options: + * --spine the spine text file to encode (required) + * --memoir-url optional memoir pointer baked into the manifest + * --out output basepath (default .pap) + * --public REQUIRED acknowledgment (see below) + * + * Sealed mode (manifest v2, docs/PAP-SEALED.md): + * --seal encrypt the spine; only the recipients can read it + * --to X25519 recipient public key: 64 hex chars or a + * path to a .pub / SPKI PEM file. Repeat per recipient. + * --passphrase-env also seal to the passphrase held in env var VAR + * + * Chain-state overrides (default: read live from the Lotor home): + * --key Ed25519 private key (default: the chain key) + * --chain-head chain head hash (default: current head) + * --chain-pubkey-fp raw pubkey fingerprint (default: chain pubkey) + * --seq chain seq (default: current head seq) + * --timestamp epoch ms (default: now) + * + * By default this tool reads the operator's live chain state: the head + * hash, seq, and signing key come from the Lotor home (LOTOR_HOME or + * ~/.lotor). The override flags exist so tests can run against synthetic + * keys without touching a real chain, the same seam the prototype proved. + * + * The --public flag is required and unlocks nothing except the operator's + * acknowledgment that everything about to be encoded is going onto a + * medium anyone can scan. See KNOWN-LIMITS 45. The signature ties the bundle + * to the key fingerprint it carries; it does not make the bundle safe to + * distribute, and it names the author only to a reader who compares that + * fingerprint with one they trust. Sealed + * mode still needs --public: the ciphertext, the recipient count, the + * signer, the chain head, the seq and the timestamp stay readable by anyone. + * + * Signing uses the CHAIN key, not the approval key (KNOWN-LIMITS 48): + * bundle authenticity is chain-key strength, and the chain key is stored + * plaintext at rest (limit 8). "Cannot be forged" means "cannot be forged + * without the chain key file", and anyone who can read that file can sign + * bundles in your name. + * + * Emits .png, .svg, and .bin. + * + * Ported from projects/spinoff/pap-prototype/cli/pap-export.js + * (WO-PAP-01) with the chain-state wiring the proposal's D1 specified. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { pack } from '../src/publish/pack.js'; +import { bundleToPng, bundleToSvg } from '../src/publish/qr.js'; +import { resolveHome } from '../src/home.js'; +import { loadChain, loadOrCreateKeyPair } from '../src/store/index.js'; + +const USAGE = [ + 'usage: node bin/pap-export.js --spine --public [options]', + '', + ' --spine spine text file to encode (required)', + ' --public acknowledge a QR is a broadcast medium (required)', + ' --memoir-url optional memoir pointer', + ' --out output basepath (default .pap)', + '', + 'sealed mode (only the recipients can read the spine):', + ' --seal encrypt, then sign', + ' --to X25519 recipient public key; repeat per recipient', + ' (make one with: node bin/pap-keygen.js --out )', + ' --passphrase-env also seal to the passphrase in env var VAR', + '', + 'chain-state overrides: --key --chain-head --chain-pubkey-fp ', + ' --seq --timestamp ', + '', + 'What a bundle proves, and what it does not:', + ' Anyone can check that the bundle is untouched and verifies under the key', + ' fingerprint it carries. That names you only to a reader who compares the', + ' fingerprint with one they already trust, and only while the chain key is', + ' secret. The chain key is stored unencrypted on this machine (KNOWN-LIMITS 8, 48):', + ' anyone who can read the key file can sign bundles in your name.', + ' A signature proves custody, never that the spine is correct (KNOWN-LIMITS 46).', + ' Sealed mode hides the spine, not the fact of the bundle: the ciphertext,', + ' recipient count, signer, chain head, seq and time are public (KNOWN-LIMITS 45).', +].join('\n'); + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + process.stdout.write(USAGE + '\n'); + process.exit(0); + } + + if (!args.public) { + die( + 'refusing to encode without --public.\n' + + 'A QR is a broadcast medium. Anyone who scans this will read the spine.\n' + + (args.seal + ? 'Sealed, they cannot read the spine, but they can read the ciphertext, the\n' + + 'recipient count, the signer, the chain head, the seq and the timestamp.\n' + : '') + + 'Add --public to acknowledge and proceed.', + ); + } + if (!args.spine) die('missing --spine '); + + let seal; + if (args.seal) { + const to = args.to.map(readRecipientKey); + let passphrase; + if (args.passphraseEnv) { + passphrase = process.env[args.passphraseEnv]; + if (!passphrase) die(`--passphrase-env ${args.passphraseEnv}: that environment variable is empty or unset`); + } + if (to.length === 0 && passphrase === undefined) { + die('--seal needs at least one --to or --passphrase-env '); + } + seal = { to, passphrase }; + } else if (args.to.length > 0 || args.passphraseEnv) { + die('--to and --passphrase-env only make sense with --seal'); + } + + const spine = fs.readFileSync(args.spine, 'utf8'); + + // Chain-state defaults: read the live Lotor home unless every field is + // overridden. Tests override all four; operators override none. + let chainHeadHash, chainPubkeyFp, seq, signingKey; + const needChain = !args.chainHead || !args.chainPubkeyFp || args.seq === undefined || !args.key; + if (needChain) { + const home = resolveHome(); + const entries = loadChain(home); + if (entries.length === 0) { + die( + `no chain found at ${home} and no explicit chain-state flags given.\n` + + 'A PAP bundle binds a spine to a receipt chain. Run Lotor first, or\n' + + 'pass --chain-head, --chain-pubkey-fp, --seq, and --key explicitly.', + ); + } + const head = entries[entries.length - 1]; + const keyPair = loadOrCreateKeyPair(home); + const pubKeyObj = crypto.createPublicKey(keyPair.publicKey); + const pubRaw = pubKeyObj.export({ format: 'der', type: 'spki' }).subarray(-32); + + chainHeadHash = args.chainHead ? hex32(args.chainHead, '--chain-head') : Buffer.from(head.hash, 'hex'); + chainPubkeyFp = args.chainPubkeyFp ? hex32(args.chainPubkeyFp, '--chain-pubkey-fp') : pubRaw; + seq = args.seq !== undefined ? Number(args.seq) : head.seq; + signingKey = args.key + ? crypto.createPrivateKey({ key: fs.readFileSync(args.key), format: 'pem' }) + : crypto.createPrivateKey(keyPair.privateKey); + } else { + chainHeadHash = hex32(args.chainHead, '--chain-head'); + chainPubkeyFp = hex32(args.chainPubkeyFp, '--chain-pubkey-fp'); + seq = Number(args.seq); + signingKey = crypto.createPrivateKey({ key: fs.readFileSync(args.key), format: 'pem' }); + } + + const timestamp = Number(args.timestamp || Date.now()); + + const result = pack({ + spine, + chainHeadHash, + chainPubkeyFp, + seq, + timestamp, + memoirUrl: args.memoirUrl || '', + signingKey, + seal, + }); + + const basepath = args.out || path.resolve(args.spine + '.pap'); + const [png, svg] = await Promise.all([ + bundleToPng(result.bundle), + bundleToSvg(result.bundle), + ]); + fs.writeFileSync(basepath + '.png', png); + fs.writeFileSync(basepath + '.svg', svg); + fs.writeFileSync(basepath + '.bin', result.bundle); + + const sealedLines = result.sealed + ? ` sealed to ${result.recipients.x25519} recipient key(s)${result.recipients.passphrase ? ' + a passphrase' : ''}` + + ` (sealing cost ${result.sealOverhead} B)\n` + : ''; + console.log( + `pap-export ok\n` + + ` spine raw ${result.spineRawLen} B\n` + + ` compressed ${result.spineCompressedLen} B (${result.compressionRatio.toFixed(2)}x)\n` + + sealedLines + + ` bundle ${result.totalLen} B / budget ${result.budget} B (headroom ${result.headroom} B)\n` + + ` chain seq ${seq}\n` + + ` wrote ${basepath}.png\n` + + ` wrote ${basepath}.svg\n` + + ` wrote ${basepath}.bin`, + ); +} + +function parseArgs(argv) { + const out = { public: false, seal: false, to: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + switch (a) { + case '--public': out.public = true; break; + case '--spine': out.spine = argv[++i]; break; + case '--key': out.key = argv[++i]; break; + case '--chain-head': out.chainHead = argv[++i]; break; + case '--chain-pubkey-fp': out.chainPubkeyFp = argv[++i]; break; + case '--seq': out.seq = argv[++i]; break; + case '--memoir-url': out.memoirUrl = argv[++i]; break; + case '--timestamp': out.timestamp = argv[++i]; break; + case '--out': out.out = argv[++i]; break; + case '--seal': out.seal = true; break; + case '--to': out.to.push(argv[++i]); break; + case '--passphrase-env': out.passphraseEnv = argv[++i]; break; + case '-h': case '--help': out.help = true; break; + default: + if (!a.startsWith('-')) break; + die(`unknown flag: ${a}`); + } + } + return out; +} + +// A recipient key on the command line is either 64 hex chars or a file +// holding hex (a .pub from pap-keygen) or an SPKI PEM. pack() validates it. +function readRecipientKey(s) { + if (typeof s !== 'string' || s.length === 0) die('--to needs a key or a key file'); + if (/^[0-9a-f]{64}$/i.test(s)) return s; + if (!fs.existsSync(s)) die(`--to ${s}: not 64 hex characters and not a readable file`); + return fs.readFileSync(s, 'utf8').trim(); +} + +function hex32(s, name) { + if (typeof s !== 'string' || !/^[0-9a-f]{64}$/i.test(s)) { + die(`${name} must be 64 hex characters (32 bytes); got ${s}`); + } + return Buffer.from(s, 'hex'); +} + +function die(msg) { + process.stderr.write('pap-export: ' + msg + '\n'); + process.exit(2); +} + +main().catch((err) => { + if (err && err.code === 'PAP_BUDGET_EXCEEDED') { + process.stderr.write('pap-export: ' + err.message + '\n'); + process.exit(3); + } + if (err && (err.code === 'PAP_BAD_KEY' || err.code === 'PAP_SPINE_TOO_LARGE' || err.code === 'PAP_KDF_TOO_WEAK')) { + process.stderr.write('pap-export: ' + err.message + '\n'); + process.exit(2); + } + process.stderr.write('pap-export: ' + (err && err.stack || err) + '\n'); + process.exit(1); +}); diff --git a/proposals/pap-sealed/bin.pap-keygen.js b/proposals/pap-sealed/bin.pap-keygen.js new file mode 100644 index 0000000..2bc660e --- /dev/null +++ b/proposals/pap-sealed/bin.pap-keygen.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * pap-keygen: make an X25519 recipient keypair for sealed PAP bundles. + * + * Usage: + * node bin/pap-keygen.js --out + * + * Writes .key (PKCS8 PEM, the secret; keep it) and .pub + * (64 hex chars; hand it to whoever seals bundles for you). Refuses to + * overwrite either file. + * + * A recipient key is not the chain key and signs nothing. It only opens + * bundles sealed to it. Anyone who later obtains the .key file opens every + * bundle ever sealed to it, and those bundles are QR codes that may have + * been photographed anywhere (KNOWN-LIMITS, sealed-mode entry). Treat it + * like the chain key: it is stored unencrypted, here too. + */ + +import fs from 'node:fs'; +import { generateRecipientKeypair } from '../src/publish/pack.js'; + +function main() { + const argv = process.argv.slice(2); + let out; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--out') out = argv[++i]; + else if (argv[i] === '-h' || argv[i] === '--help') { + process.stdout.write('usage: node bin/pap-keygen.js --out \n'); + process.exit(0); + } else die(`unknown argument: ${argv[i]}`); + } + if (!out) die('missing --out '); + for (const p of [out + '.key', out + '.pub']) { + if (fs.existsSync(p)) die(`${p} exists; refusing to overwrite a key`); + } + const kp = generateRecipientKeypair(); + fs.writeFileSync(out + '.key', kp.privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 }); + fs.writeFileSync(out + '.pub', kp.publicKeyRaw.toString('hex') + '\n'); + process.stdout.write(`pap-keygen ok\n secret ${out}.key\n public ${out}.pub ${kp.publicKeyRaw.toString('hex')}\n`); +} + +function die(msg) { + process.stderr.write('pap-keygen: ' + msg + '\n'); + process.exit(2); +} + +main(); diff --git a/proposals/pap-sealed/manifest.v2-block.txt b/proposals/pap-sealed/manifest.v2-block.txt new file mode 100644 index 0000000..68da437 --- /dev/null +++ b/proposals/pap-sealed/manifest.v2-block.txt @@ -0,0 +1,178 @@ +// ---------- v2: sealed ---------- +// +// v2 layout, in order (all integers big-endian; spec docs/PAP-SEALED.md): +// version u8 = 0x02 +// chainHeadHash 32B +// chainPubkeyFp 32B +// seq u32 +// timestamp u64 +// keyCommit 32B SHA-256 commitment to the content key +// x25519Count u8 number of X25519 recipient stanzas +// passphraseCount u8 0 or 1 +// ephemeralPub 32B present only when x25519Count > 0 +// x25519Wrapped x25519Count * 48B (wrapped content key + GCM tag) +// passphrase stanza present only when passphraseCount = 1: +// salt 16B, iterations u32, wrapped 48B +// ---- everything above is the header, and is the AEAD's associated data ---- +// ciphertextLen u16 +// ciphertext AES-256-GCM output with the 16-byte tag appended +// +// The outer Ed25519 signature (64B, appended by pack.js) covers all of it, +// prefixed by the domain string "lotor/pap/v2/outer" and one 0x00 byte. The +// prefix is not part of the layout and not on the wire; v1 signs the bare +// manifest and is unchanged. + +const VERSION_SEALED = 0x02; +const KEY_COMMIT_LEN = 32; +const EPHEMERAL_PUB_LEN = 32; +const WRAPPED_KEY_LEN = 48; +const PASSPHRASE_SALT_LEN = 16; +const SEALED_FIXED_LEN = 1 + CHAIN_HEAD_HASH_LEN + CHAIN_PUBKEY_FP_LEN + 4 + 8 + KEY_COMMIT_LEN + 1 + 1; + +/** + * Encode the v2 header (the AEAD associated data). Does NOT sign or encrypt. + */ +function encodeSealedHeader(h) { + requireBufferOfLength(h.chainHeadHash, CHAIN_HEAD_HASH_LEN, 'chainHeadHash'); + requireBufferOfLength(h.chainPubkeyFp, CHAIN_PUBKEY_FP_LEN, 'chainPubkeyFp'); + requireU32(h.seq, 'seq'); + requireSafeInteger(h.timestamp, 'timestamp'); + requireBufferOfLength(h.keyCommit, KEY_COMMIT_LEN, 'keyCommit'); + const wrapped = h.x25519Wrapped || []; + if (wrapped.length > 0xFF) throw new Error(`too many recipients: ${wrapped.length}, max 255`); + if (wrapped.length > 0) requireBufferOfLength(h.ephemeralPub, EPHEMERAL_PUB_LEN, 'ephemeralPub'); + for (const w of wrapped) requireBufferOfLength(w, WRAPPED_KEY_LEN, 'x25519 wrapped key'); + const p = h.passphrase || null; + if (p) { + requireBufferOfLength(p.salt, PASSPHRASE_SALT_LEN, 'passphrase salt'); + requireU32(p.iterations, 'passphrase iterations'); + requireBufferOfLength(p.wrapped, WRAPPED_KEY_LEN, 'passphrase wrapped key'); + } + if (wrapped.length === 0 && !p) throw new Error('a sealed bundle needs at least one recipient or a passphrase'); + + const fixed = Buffer.alloc(SEALED_FIXED_LEN); + let off = 0; + fixed.writeUInt8(VERSION_SEALED, off); off += 1; + h.chainHeadHash.copy(fixed, off); off += CHAIN_HEAD_HASH_LEN; + h.chainPubkeyFp.copy(fixed, off); off += CHAIN_PUBKEY_FP_LEN; + fixed.writeUInt32BE(h.seq, off); off += 4; + writeUInt64BE(fixed, BigInt(h.timestamp), off); off += 8; + h.keyCommit.copy(fixed, off); off += KEY_COMMIT_LEN; + fixed.writeUInt8(wrapped.length, off); off += 1; + fixed.writeUInt8(p ? 1 : 0, off); off += 1; + const parts = [fixed]; + if (wrapped.length > 0) parts.push(h.ephemeralPub, ...wrapped); + if (p) { + const iter = Buffer.alloc(4); + iter.writeUInt32BE(p.iterations, 0); + parts.push(p.salt, iter, p.wrapped); + } + return Buffer.concat(parts); +} + +/** + * Header || u16 ciphertextLen || ciphertext. Does NOT sign. + */ +function encodeSealedManifest(headerBytes, ciphertext) { + requireBuffer(headerBytes, 'headerBytes'); + requireBuffer(ciphertext, 'ciphertext'); + if (ciphertext.length > 0xFFFF) { + throw new Error(`ciphertext too long: ${ciphertext.length} bytes, max 65535`); + } + const len = Buffer.alloc(2); + len.writeUInt16BE(ciphertext.length, 0); + return Buffer.concat([headerBytes, len, ciphertext]); +} + +/** + * Decode a v2 manifest. Returns the public fields plus headerBytes (the AEAD + * associated data) and the ciphertext. Nothing here needs a key. + */ +function decodeSealedManifest(bytes) { + requireBuffer(bytes, 'manifest bytes'); + if (bytes.length < SEALED_FIXED_LEN + 2) { + throw new Error(`sealed manifest too short: ${bytes.length} bytes, need at least ${SEALED_FIXED_LEN + 2}`); + } + let off = 0; + const version = bytes.readUInt8(off); off += 1; + if (version !== VERSION_SEALED) { + throw new Error(`unknown sealed manifest version: 0x${version.toString(16)}, expected 0x${VERSION_SEALED.toString(16)}`); + } + const chainHeadHash = Buffer.from(bytes.subarray(off, off + CHAIN_HEAD_HASH_LEN)); off += CHAIN_HEAD_HASH_LEN; + const chainPubkeyFp = Buffer.from(bytes.subarray(off, off + CHAIN_PUBKEY_FP_LEN)); off += CHAIN_PUBKEY_FP_LEN; + const seq = bytes.readUInt32BE(off); off += 4; + const timestampBig = readUInt64BE(bytes, off); off += 8; + const timestamp = Number(timestampBig); + if (!Number.isSafeInteger(timestamp)) { + throw new Error(`timestamp ${timestampBig} exceeds safe integer range`); + } + const keyCommit = Buffer.from(bytes.subarray(off, off + KEY_COMMIT_LEN)); off += KEY_COMMIT_LEN; + const x25519Count = bytes.readUInt8(off); off += 1; + const passphraseCount = bytes.readUInt8(off); off += 1; + if (passphraseCount > 1) throw new Error(`passphraseCount must be 0 or 1, got ${passphraseCount}`); + if (x25519Count === 0 && passphraseCount === 0) throw new Error('sealed manifest has no recipients'); + + const need = (n, what) => { + if (off + n > bytes.length) throw new Error(`sealed manifest ends early inside ${what}`); + }; + let ephemeralPub = null; + const x25519Wrapped = []; + if (x25519Count > 0) { + need(EPHEMERAL_PUB_LEN, 'ephemeralPub'); + ephemeralPub = Buffer.from(bytes.subarray(off, off + EPHEMERAL_PUB_LEN)); off += EPHEMERAL_PUB_LEN; + for (let i = 0; i < x25519Count; i++) { + need(WRAPPED_KEY_LEN, `x25519 stanza ${i}`); + x25519Wrapped.push(Buffer.from(bytes.subarray(off, off + WRAPPED_KEY_LEN))); off += WRAPPED_KEY_LEN; + } + } + let passphrase = null; + if (passphraseCount === 1) { + need(PASSPHRASE_SALT_LEN + 4 + WRAPPED_KEY_LEN, 'passphrase stanza'); + const salt = Buffer.from(bytes.subarray(off, off + PASSPHRASE_SALT_LEN)); off += PASSPHRASE_SALT_LEN; + const iterations = bytes.readUInt32BE(off); off += 4; + const wrapped = Buffer.from(bytes.subarray(off, off + WRAPPED_KEY_LEN)); off += WRAPPED_KEY_LEN; + passphrase = { salt, iterations, wrapped }; + } + const headerBytes = Buffer.from(bytes.subarray(0, off)); + need(2, 'ciphertextLen'); + const ciphertextLen = bytes.readUInt16BE(off); off += 2; + if (off + ciphertextLen !== bytes.length) { + throw new Error(`ciphertextLen ${ciphertextLen} does not match trailing bytes ${bytes.length - off}`); + } + const ciphertext = Buffer.from(bytes.subarray(off, off + ciphertextLen)); + + return { + version, + chainHeadHash, + chainPubkeyFp, + seq, + timestamp, + keyCommit, + ephemeralPub, + x25519Wrapped, + passphrase, + headerBytes, + ciphertext, + manifestLen: bytes.length, + }; +} + +export { + VERSION, + VERSION_SEALED, + HEADER_FIXED_LEN, + SPINE_LEN_TAG_BYTES, + SIG_LEN, + QR_V40_BINARY_ECL_CAPACITY, + USEFUL_PAYLOAD_BUDGET, + KEY_COMMIT_LEN, + EPHEMERAL_PUB_LEN, + WRAPPED_KEY_LEN, + PASSPHRASE_SALT_LEN, + SEALED_FIXED_LEN, + encodeManifest, + decodeManifest, + encodeSealedHeader, + encodeSealedManifest, + decodeSealedManifest, +}; diff --git a/proposals/pap-sealed/pack.sealed-block.txt b/proposals/pap-sealed/pack.sealed-block.txt new file mode 100644 index 0000000..53b74f9 --- /dev/null +++ b/proposals/pap-sealed/pack.sealed-block.txt @@ -0,0 +1,308 @@ +// ---------- sealed (v2) ---------- +// +// Encrypt-then-sign (WO-CARTRIDGE-SEAL, spec docs/PAP-SEALED.md). The outer +// Ed25519 signature covers the ciphertext manifest, so anyone can check that +// the bundle is unaltered and self-consistent with the key fingerprint it +// carries, without a key. Whether that fingerprint is the author you mean is a +// comparison the reader makes against a fingerprint they already trust. Inside +// the ciphertext, a second signature by the same key binds the plaintext to +// the exact header it was sealed under, so a key holder cannot be handed a +// re-encrypted or re-addressed swap. A public key commitment makes every +// recipient read the same plaintext. + +// Cap on a decompressed spine, v1 and v2. A bundle is at most 2,953 B, but +// brotli expands tiny inputs enormously, and the decompression runs before the +// inner signature can be checked. Exceeding it is PAP_SPINE_TOO_LARGE. +const MAX_SPINE_RAW_BYTES = 4 * 1024 * 1024; + +const TRUST_SELF_CONSISTENT = 'self-consistent'; +const TRUST_SUPPLIED_KEY = 'verified-against-supplied-key'; + +function papError(code, message) { + const err = new Error(message); + err.code = code; + return err; +} + +/** brotli with an output cap and PAP error codes. Never a raw zlib error. */ +function boundedBrotli(compressed) { + try { + return zlib.brotliDecompressSync(compressed, { maxOutputLength: MAX_SPINE_RAW_BYTES }); + } catch (e) { + if (e && e.code === 'ERR_BUFFER_TOO_LARGE') { + throw papError('PAP_SPINE_TOO_LARGE', + `spine decompresses past the ${MAX_SPINE_RAW_BYTES}-byte cap; refusing to expand it`); + } + throw papError('PAP_DECOMPRESS_FAILED', `spine is not valid brotli (${(e && (e.code || e.message)) || 'unknown error'})`); + } +} + +/** + * Pick the key the outer signature is checked under, and say on what basis. + * No supplied key: the bundle's own embedded fingerprint, which makes the + * result "self-consistent" and nothing more. A supplied key must BE that + * fingerprint (raw-key equality), else PAP_SIGNER_MISMATCH before any + * signature work; then the result is "verified against the supplied key". + */ +function resolveVerifier(supplied, chainPubkeyFp) { + if (!supplied) { + return { verifyKey: rawEd25519PublicKeyToKeyObject(chainPubkeyFp), trust: TRUST_SELF_CONSISTENT }; + } + let pub = null; + let raw = null; + try { + pub = supplied.type === 'private' ? crypto.createPublicKey(supplied) : supplied; + if (pub.asymmetricKeyType === 'ed25519') raw = pub.export({ format: 'der', type: 'spki' }).subarray(-32); + } catch { + raw = null; + } + if (raw === null || raw.length !== chainPubkeyFp.length || !crypto.timingSafeEqual(raw, chainPubkeyFp)) { + throw papError('PAP_SIGNER_MISMATCH', + 'the supplied verify key is not the key this bundle carries (its embedded fingerprint differs): ' + + 'either this is not the signer you expected, or the bundle is not what it claims'); + } + return { verifyKey: pub, trust: TRUST_SUPPLIED_KEY }; +} + +function signatureInvalid() { + return papError('PAP_SIGNATURE_INVALID', + 'signature verification failed: the bundle does not verify under the key whose fingerprint it carries ' + + '(it was altered after signing, or it was not signed by that key)'); +} + +/** + * pack() with inputs.seal set: + * @param {Object} inputs.seal + * @param {Array} [inputs.seal.to] - X25519 recipient public + * keys (32-byte Buffer, 64 hex chars, or SPKI PEM) + * @param {string} [inputs.seal.passphrase] - optional passphrase recipient + * @param {number} [inputs.seal.iterations] - PBKDF2 iterations (default + * 600000; below 100000 is refused with PAP_KDF_TOO_WEAK) + */ +function packSealed(inputs) { + if (typeof inputs.spine !== 'string' || inputs.spine.length === 0) { + throw new Error('spine must be a non-empty string'); + } + const seal = inputs.seal; + const sealer = createSealer({ to: seal.to || [], passphrase: seal.passphrase, iterations: seal.iterations }); + const recipientCount = sealer.x25519Wrapped.length; + const hasPassphrase = sealer.passphrase !== null; + + const spineRaw = Buffer.from(inputs.spine, 'utf8'); + if (spineRaw.length > MAX_SPINE_RAW_BYTES) { + throw papError('PAP_SPINE_TOO_LARGE', + `spine is ${spineRaw.length} bytes; a sealed bundle's reader refuses anything over ${MAX_SPINE_RAW_BYTES}`); + } + const spineCompressed = brotliCompressMax(spineRaw); + const memoirUrlBytes = Buffer.from(inputs.memoirUrl || '', 'utf8'); + if (memoirUrlBytes.length > 0xFFFF) { + throw new Error(`memoirUrl too long: ${memoirUrlBytes.length} bytes, max 65535`); + } + + const headerBytes = encodeSealedHeader({ + chainHeadHash: inputs.chainHeadHash, + chainPubkeyFp: inputs.chainPubkeyFp, + seq: inputs.seq, + timestamp: inputs.timestamp, + keyCommit: sealer.keyCommit, + ephemeralPub: sealer.ephemeralPub, + x25519Wrapped: sealer.x25519Wrapped, + passphrase: sealer.passphrase, + }); + + const innerSig = crypto.sign(null, innerMessage(headerBytes, spineRaw, memoirUrlBytes), inputs.signingKey); + if (innerSig.length !== SIG_LEN) { + throw new Error(`unexpected Ed25519 signature length: ${innerSig.length}, expected ${SIG_LEN}`); + } + const memoirLen = Buffer.alloc(2); + memoirLen.writeUInt16BE(memoirUrlBytes.length, 0); + const inner = Buffer.concat([innerSig, memoirLen, memoirUrlBytes, spineCompressed]); + const ciphertext = sealer.encryptBody(inner, headerBytes); + const manifestBytes = encodeSealedManifest(headerBytes, ciphertext); + + const signature = crypto.sign(null, outerMessage(manifestBytes), inputs.signingKey); + if (signature.length !== SIG_LEN) { + throw new Error(`unexpected Ed25519 signature length: ${signature.length}, expected ${SIG_LEN}`); + } + const bundle = Buffer.concat([manifestBytes, signature]); + const totalLen = bundle.length; + const budget = QR_V40_BINARY_ECL_CAPACITY; + const headroom = budget - totalLen; + // What the same spine and memoir cost as a plaintext v1 bundle, so the + // price of sealing is a measured number, not an estimate. + const plainEquivalentLen = HEADER_FIXED_LEN + memoirUrlBytes.length + SPINE_LEN_TAG_BYTES + spineCompressed.length + SIG_LEN; + const sealOverhead = totalLen - plainEquivalentLen; + + if (totalLen > budget) { + const err = new Error( + budgetMessage(spineRaw.length, spineCompressed.length, manifestBytes.length, totalLen, budget) + + `\n of which sealing costs ${sealOverhead} bytes (${recipientCount} recipient key(s)${hasPassphrase ? ' plus a passphrase' : ''}).`, + ); + err.code = 'PAP_BUDGET_EXCEEDED'; + err.spineRawLen = spineRaw.length; + err.spineCompressedLen = spineCompressed.length; + err.totalLen = totalLen; + err.budget = budget; + err.sealOverhead = sealOverhead; + throw err; + } + + return { + bundle, + manifestLen: manifestBytes.length, + sigLen: signature.length, + spineRawLen: spineRaw.length, + spineCompressedLen: spineCompressed.length, + totalLen, + budget, + headroom, + compressionRatio: spineRaw.length / spineCompressed.length, + sealed: true, + recipients: { x25519: recipientCount, passphrase: hasPassphrase }, + sealOverhead, + }; +} + +/** + * Verify the outer signature and report the public fields of a v1 or v2 + * bundle. Never decrypts and needs no key: this is what a stranger can check. + * + * What a pass means depends on info.trust. 'self-consistent': the bundle is + * unaltered and verifies under the key fingerprint it carries, which proves + * nothing about WHO holds that key until the reader compares the fingerprint + * to one they already trust. 'verified-against-supplied-key': the caller's + * key equals the embedded fingerprint and the signature verifies under it. + * + * @param {Object} inputs + * @param {Buffer} inputs.bundle + * @param {crypto.KeyObject} [inputs.verifyKey] - as in unpack(). Must equal + * the bundle's embedded fingerprint, else PAP_SIGNER_MISMATCH. + */ +function inspect(inputs) { + if (!Buffer.isBuffer(inputs.bundle)) throw new TypeError('bundle must be a Buffer'); + if (inputs.bundle.length < SIG_LEN + 1) { + throw new Error(`bundle too short: ${inputs.bundle.length} bytes`); + } + const manifestBytes = inputs.bundle.subarray(0, inputs.bundle.length - SIG_LEN); + const signature = inputs.bundle.subarray(inputs.bundle.length - SIG_LEN); + const sealed = inputs.bundle[0] === VERSION_SEALED; + const m = sealed ? decodeSealedManifest(Buffer.from(manifestBytes)) : decodeManifest(Buffer.from(manifestBytes)); + const { verifyKey, trust } = resolveVerifier(inputs.verifyKey, m.chainPubkeyFp); + // v2 signs a domain string and the manifest; v1 signs the bare manifest. + const signedBytes = sealed ? outerMessage(manifestBytes) : manifestBytes; + if (!crypto.verify(null, signedBytes, verifyKey, signature)) throw signatureInvalid(); + const info = { + version: m.version, + sealed, + trust, + chainHeadHash: m.chainHeadHash, + chainPubkeyFp: m.chainPubkeyFp, + seq: m.seq, + timestamp: m.timestamp, + bytes: inputs.bundle.length, + }; + if (sealed) { + info.recipients = { x25519: m.x25519Wrapped.length, passphrase: m.passphrase !== null }; + info.keyCommit = m.keyCommit; + info.ciphertextLen = m.ciphertext.length; + } else { + info.memoirUrl = m.memoirUrl; + } + return { info, manifest: m, verifyKey, trust }; +} + +/** A content key is 32 raw bytes or 64 hex chars; anything else is not a key. */ +function parseContentKey(input) { + if (Buffer.isBuffer(input) && input.length === 32) return input; + if (typeof input === 'string' && /^[0-9a-f]{64}$/i.test(input.trim())) return Buffer.from(input.trim(), 'hex'); + throw papError('PAP_BAD_CONTENT_KEY', 'content key must be 32 bytes or 64 hex characters'); +} + +/** + * unpack() for a v2 bundle. One of recipientKey, passphrase or contentKey + * opens it; with none, throws PAP_SEALED_NEEDS_KEY carrying err.info (the + * outer signature has already verified by then). + */ +function unpackSealed(inputs) { + const { info, manifest: m, verifyKey, trust } = inspect(inputs); + + let cek = null; + if (inputs.contentKey !== undefined && inputs.contentKey !== null) { + cek = parseContentKey(inputs.contentKey); + } else if (inputs.recipientKey === undefined && inputs.passphrase === undefined) { + const err = new Error( + `verified, sealed to ${describeRecipients(info.recipients)}; no key given, so the spine stays closed`, + ); + err.code = 'PAP_SEALED_NEEDS_KEY'; + err.info = info; + throw err; + } else { + if (inputs.passphrase !== undefined && m.passphrase) { + const kek = passphraseKek(inputs.passphrase, m.passphrase.salt, m.passphrase.iterations); + cek = aeadOpen(kek, m.passphrase.wrapped, null); + } + if (!cek && inputs.recipientKey !== undefined && m.ephemeralPub) { + const priv = parseRecipientPrivateKey(inputs.recipientKey); + for (const w of m.x25519Wrapped) { + cek = x25519Unwrap(w, priv, m.ephemeralPub); + if (cek) break; + } + } + if (!cek) { + const err = new Error('this key does not open the bundle: it is not one of the recipients (the outer signature did verify)'); + err.code = 'PAP_NOT_A_RECIPIENT'; + err.info = info; + throw err; + } + } + + if (!commitmentMatches(cek, m.keyCommit)) { + const err = new Error('content key does not match the bundle\'s key commitment: refusing to decrypt'); + err.code = 'PAP_KEY_COMMIT_MISMATCH'; + throw err; + } + const inner = openBody(cek, m.ciphertext, m.headerBytes); + if (!inner || inner.length < SIG_LEN + 2) { + const err = new Error('sealed body failed authentication'); + err.code = 'PAP_DECRYPT_FAILED'; + throw err; + } + const innerSig = inner.subarray(0, SIG_LEN); + const memoirUrlLen = inner.readUInt16BE(SIG_LEN); + if (SIG_LEN + 2 + memoirUrlLen > inner.length) { + const err = new Error('sealed body is malformed: memoir URL runs past the end'); + err.code = 'PAP_DECRYPT_FAILED'; + throw err; + } + const memoirUrlBytes = inner.subarray(SIG_LEN + 2, SIG_LEN + 2 + memoirUrlLen); + const spineCompressed = Buffer.from(inner.subarray(SIG_LEN + 2 + memoirUrlLen)); + const spineRaw = boundedBrotli(spineCompressed); + + if (!crypto.verify(null, innerMessage(m.headerBytes, spineRaw, memoirUrlBytes), verifyKey, innerSig)) { + const err = new Error( + 'inner binding failed: the plaintext was not signed under this header by this key ' + + '(re-encrypted, re-addressed, or re-signed by someone else)', + ); + err.code = 'PAP_INNER_BINDING_INVALID'; + throw err; + } + + return { + manifest: { ...info, memoirUrl: memoirUrlBytes.toString('utf8') }, + trust, + spine: spineRaw.toString('utf8'), + spineRawLen: spineRaw.length, + spineCompressedLen: spineCompressed.length, + // Anyone holding this opens the bundle, and the public commitment proves + // it is the right key. Publishing it is how a sealed bundle is revealed. + contentKey: cek, + }; +} + +function describeRecipients(r) { + const parts = []; + if (r.x25519 > 0) parts.push(`${r.x25519} recipient key${r.x25519 === 1 ? '' : 's'}`); + if (r.passphrase) parts.push('a passphrase'); + return parts.join(' and '); +} + diff --git a/proposals/pap-sealed/src.publish.seal.js b/proposals/pap-sealed/src.publish.seal.js new file mode 100644 index 0000000..7b13c86 --- /dev/null +++ b/proposals/pap-sealed/src.publish.seal.js @@ -0,0 +1,362 @@ +/** + * PAP seal: the crypto under a sealed (v2) bundle. No layout here; that is + * manifest.js. No signing of the outer bundle here; that is pack.js. + * + * Construction (spec: docs/PAP-SEALED.md), published primitives only: + * - content key (CEK): 32 random bytes, fresh per bundle + * - key commitment: SHA-256("lotor/pap/v2/key-commit" 0x00 || CEK), public. + * AES-GCM is not key-committing, so a separate commitment makes every + * recipient check the CEK it unwrapped against one public value. It is + * defence in depth (forging two keys that open one GCM body to different + * plaintexts is far from free against random keys, but it is not a + * property GCM promises) plus the verifiable reveal: a published CEK is + * checked against this value by anyone. + * - body: AES-256-GCM under HKDF-SHA256(CEK, info "lotor/pap/v2/body"), + * 12-byte zero nonce, header bytes as associated data + * - X25519 recipient: one ephemeral X25519 key per bundle; per recipient + * KEK = HKDF-SHA256(X25519(eph, recipient), salt eph || recipient, + * info "lotor/pap/v2/x25519-wrap"); wrapped = AES-256-GCM(KEK, CEK), 48 B + * - passphrase: KEK = PBKDF2-HMAC-SHA256(passphrase, salt16, iterations); + * wrapped = AES-256-GCM(KEK, CEK), 48 B + * + * Why a zero nonce is safe: every key it is used with is used exactly once. + * The body key comes from a fresh CEK, each X25519 KEK from a fresh ephemeral + * key, each passphrase KEK from a fresh salt. Same reasoning, same shape as + * age's file-key wrapping. AES-256-GCM (not XChaCha20-Poly1305) because it is + * in both node:crypto and WebCrypto, so the offline reader needs no AEAD + * library. + * + * That reasoning only holds if no caller can pick the key. So nothing here is + * exported that encrypts under a caller-supplied key. The only way in is + * createSealer(): it makes the CEK, the ephemeral key and every KEK inside its + * own closure, hands back the public stanzas, and its encryptBody() runs once. + * A second call throws. The decrypt side (aeadOpen, openBody, x25519Unwrap) is + * exported because opening under a key you hold is not the hazard. + * + * WO-CARTRIDGE-SEAL, 2026-10-26; amended the same day after the wringer review. + * Queued for a signing sitting: src/publish is core (src/grant/core-paths.js + * CORE_DIRS). + */ + +import crypto from 'node:crypto'; +import { PASSPHRASE_SALT_LEN } from './manifest.js'; + +const CEK_LEN = 32; +const GCM_TAG_LEN = 16; +const ZERO_NONCE = Buffer.alloc(12); +const NUL = Buffer.from([0]); + +const DOMAIN_KEY_COMMIT = Buffer.from('lotor/pap/v2/key-commit', 'utf8'); +const DOMAIN_BODY = 'lotor/pap/v2/body'; +const DOMAIN_X25519_WRAP = 'lotor/pap/v2/x25519-wrap'; +const DOMAIN_INNER = Buffer.from('lotor/pap/v2/inner', 'utf8'); +const DOMAIN_OUTER = Buffer.from('lotor/pap/v2/outer', 'utf8'); + +const DEFAULT_PBKDF2_ITERATIONS = 600000; +// Seal-time floor. PBKDF2 is not memory-hard (the price of WebCrypto support, +// KNOWN-LIMITS 45); below this count a stolen QR is cheap to attack. The count +// is carried in the bundle, so older or other writers are still readable. +const MIN_PBKDF2_ITERATIONS = 100000; +const MAX_PBKDF2_ITERATIONS = 10000000; + +// SPKI / PKCS8 DER prefixes for X25519, per RFC 8410 (OID 1.3.101.110). +const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex'); +const X25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex'); + +function papError(code, message) { + const err = new Error(message); + err.code = code; + return err; +} + +/** Re-throw anything a key parser raised as a coded PAP_BAD_KEY. */ +function badKey(e, what) { + if (e && e.code === 'PAP_BAD_KEY') return e; + return papError('PAP_BAD_KEY', `${what}: ${e && e.message ? e.message : String(e)}`); +} + +function sha256(...parts) { + const h = crypto.createHash('sha256'); + for (const p of parts) h.update(p); + return h.digest(); +} + +function keyCommitment(cek) { + return sha256(DOMAIN_KEY_COMMIT, NUL, cek); +} + +function commitmentMatches(cek, commit) { + const mine = keyCommitment(cek); + return commit.length === mine.length && crypto.timingSafeEqual(mine, commit); +} + +function bodyKey(cek) { + return Buffer.from(crypto.hkdfSync('sha256', cek, Buffer.alloc(0), DOMAIN_BODY, 32)); +} + +/** + * Module-private on purpose. AES-GCM with the fixed zero nonce is safe only + * for a key that is used once, and this function cannot know that. + */ +function aeadSeal(key, plaintext, aad) { + const c = crypto.createCipheriv('aes-256-gcm', key, ZERO_NONCE); + if (aad) c.setAAD(aad); + const body = Buffer.concat([c.update(plaintext), c.final()]); + return Buffer.concat([body, c.getAuthTag()]); +} + +/** Returns the plaintext, or null when authentication fails. */ +function aeadOpen(key, sealed, aad) { + if (sealed.length < GCM_TAG_LEN) return null; + const d = crypto.createDecipheriv('aes-256-gcm', key, ZERO_NONCE); + if (aad) d.setAAD(aad); + d.setAuthTag(sealed.subarray(sealed.length - GCM_TAG_LEN)); + try { + return Buffer.concat([d.update(sealed.subarray(0, sealed.length - GCM_TAG_LEN)), d.final()]); + } catch { + return null; + } +} + +/** Decrypt the sealed body under a content key. Null when it does not authenticate. */ +function openBody(cek, ciphertext, headerBytes) { + return aeadOpen(bodyKey(cek), ciphertext, headerBytes); +} + +// ---------- X25519 ---------- + +function x25519PublicFromRaw(raw32) { + if (!Buffer.isBuffer(raw32) || raw32.length !== 32) throw papError('PAP_BAD_KEY', 'X25519 public key must be 32 bytes'); + try { + return crypto.createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, raw32]), format: 'der', type: 'spki' }); + } catch (e) { + throw badKey(e, 'X25519 public key rejected'); + } +} + +function x25519PrivateFromRaw(raw32) { + if (!Buffer.isBuffer(raw32) || raw32.length !== 32) throw papError('PAP_BAD_KEY', 'X25519 private key must be 32 bytes'); + try { + return crypto.createPrivateKey({ key: Buffer.concat([X25519_PKCS8_PREFIX, raw32]), format: 'der', type: 'pkcs8' }); + } catch (e) { + throw badKey(e, 'X25519 private key rejected'); + } +} + +function x25519RawPublic(keyObj) { + const pub = keyObj.type === 'private' ? crypto.createPublicKey(keyObj) : keyObj; + return pub.export({ format: 'der', type: 'spki' }).subarray(-32); +} + +function generateRecipientKeypair() { + const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519'); + return { + publicKey, + privateKey, + publicKeyRaw: x25519RawPublic(publicKey), + privateKeyRaw: privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(-32), + }; +} + +/** Accepts a 32-byte Buffer, 64 hex chars, or an SPKI PEM string. */ +function parseRecipientPublicKey(input) { + try { + if (Buffer.isBuffer(input)) return x25519RawPublic(x25519PublicFromRaw(input)); + const s = String(input).trim(); + if (/^[0-9a-f]{64}$/i.test(s)) return x25519RawPublic(x25519PublicFromRaw(Buffer.from(s, 'hex'))); + const k = crypto.createPublicKey(s); + if (k.asymmetricKeyType !== 'x25519') throw papError('PAP_BAD_KEY', `recipient key is ${k.asymmetricKeyType}, expected x25519`); + return x25519RawPublic(k); + } catch (e) { + throw badKey(e, 'recipient public key is not usable'); + } +} + +/** Accepts a KeyObject, a 32-byte Buffer, 64 hex chars, or a PKCS8 PEM string. */ +function parseRecipientPrivateKey(input) { + try { + let k; + if (input && typeof input === 'object' && input.type === 'private') k = input; + else if (Buffer.isBuffer(input)) k = x25519PrivateFromRaw(input); + else { + const s = String(input).trim(); + k = /^[0-9a-f]{64}$/i.test(s) ? x25519PrivateFromRaw(Buffer.from(s, 'hex')) : crypto.createPrivateKey(s); + } + if (k.asymmetricKeyType !== 'x25519') throw papError('PAP_BAD_KEY', `recipient key is ${k.asymmetricKeyType}, expected x25519`); + return k; + } catch (e) { + throw badKey(e, 'recipient private key is not usable'); + } +} + +/** + * Derive the key-encryption key for one stanza. Both keys are validated as + * X25519 first, so a failure of the Diffie-Hellman itself can only be a + * degenerate (low-order) peer key: OpenSSL refuses to return an all-zero + * shared secret, and if it ever did, the explicit check below catches it. That + * one case is PAP_X25519_LOW_ORDER; every other failure is a PAP_BAD_KEY. + */ +function x25519Kek(privateKey, peerRaw, ephPubRaw, recipientPubRaw) { + if (!privateKey || privateKey.type !== 'private' || privateKey.asymmetricKeyType !== 'x25519') { + throw papError('PAP_BAD_KEY', 'recipient key is not an x25519 private key, expected x25519'); + } + const peer = x25519PublicFromRaw(peerRaw); + let shared; + try { + shared = crypto.diffieHellman({ privateKey, publicKey: peer }); + } catch { + throw papError('PAP_X25519_LOW_ORDER', 'X25519 refused the peer key (low-order point)'); + } + // An all-zero shared secret means a low-order peer key. Refuse it rather + // than derive a key an attacker can predict. + if (shared.every((b) => b === 0)) throw papError('PAP_X25519_LOW_ORDER', 'X25519 produced an all-zero shared secret'); + return Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.concat([ephPubRaw, recipientPubRaw]), DOMAIN_X25519_WRAP, 32)); +} + +/** + * Returns the CEK, or null when this stanza is not for this key. Null covers + * exactly two things: the stanza does not authenticate under this key, and a + * low-order peer key (refused, so it is not a stanza for anyone). Any other + * failure is a coded PAP_BAD_KEY and is not swallowed. + */ +function x25519Unwrap(wrapped, recipientPrivateKey, ephPubRaw) { + if (!recipientPrivateKey || recipientPrivateKey.type !== 'private' || recipientPrivateKey.asymmetricKeyType !== 'x25519') { + throw papError('PAP_BAD_KEY', 'recipient key is not an x25519 private key, expected x25519'); + } + const recipientPubRaw = x25519RawPublic(recipientPrivateKey); + let kek; + try { + kek = x25519Kek(recipientPrivateKey, ephPubRaw, ephPubRaw, recipientPubRaw); + } catch (e) { + if (e && e.code === 'PAP_X25519_LOW_ORDER') return null; + throw badKey(e, 'cannot unwrap with this key'); + } + return aeadOpen(kek, wrapped, null); +} + +// ---------- passphrase ---------- + +function passphraseKek(passphrase, salt, iterations) { + if (typeof passphrase !== 'string' || passphrase.length === 0) throw new Error('passphrase must be a non-empty string'); + if (!Number.isInteger(iterations) || iterations < 1 || iterations > MAX_PBKDF2_ITERATIONS) { + throw papError('PAP_KDF_BAD_PARAMS', `PBKDF2 iterations must be 1..${MAX_PBKDF2_ITERATIONS}, got ${iterations}`); + } + return crypto.pbkdf2Sync(Buffer.from(passphrase, 'utf8'), salt, iterations, 32, 'sha256'); +} + +// ---------- sealing (the only way in) ---------- + +/** + * Start one seal. Returns the public stanzas and a body encryptor that runs + * exactly once. The content key, the ephemeral private key and every KEK live + * and die in this closure. + * + * @param {Object} opts + * @param {Array} [opts.to] - X25519 recipient public keys + * @param {string} [opts.passphrase] - optional passphrase recipient + * @param {number} [opts.iterations] - PBKDF2 iterations, default 600000, + * floor 100000 (PAP_KDF_TOO_WEAK below it) + * @returns {{ keyCommit: Buffer, ephemeralPub: Buffer|null, + * x25519Wrapped: Buffer[], passphrase: {salt, iterations, wrapped}|null, + * encryptBody: (plaintext: Buffer, headerBytes: Buffer) => Buffer }} + */ +function createSealer({ to = [], passphrase, iterations } = {}) { + const recipients = to.map(parseRecipientPublicKey); + const hasPassphrase = passphrase !== undefined && passphrase !== null; + if (recipients.length === 0 && !hasPassphrase) { + throw new Error('seal needs at least one recipient (seal.to) or a passphrase'); + } + if (new Set(recipients.map((k) => k.toString('hex'))).size !== recipients.length) { + throw new Error('seal.to lists the same recipient twice'); + } + let rounds = null; + if (hasPassphrase) { + rounds = iterations === undefined ? DEFAULT_PBKDF2_ITERATIONS : iterations; + if (Number.isInteger(rounds) && rounds < MIN_PBKDF2_ITERATIONS) { + throw papError('PAP_KDF_TOO_WEAK', + `PBKDF2 iterations ${rounds} is below the floor of ${MIN_PBKDF2_ITERATIONS}; the count is public in the bundle and a QR can be attacked offline`); + } + } + + const cek = crypto.randomBytes(CEK_LEN); + let ephemeralPub = null; + let x25519Wrapped = []; + if (recipients.length > 0) { + const eph = crypto.generateKeyPairSync('x25519'); + ephemeralPub = x25519RawPublic(eph.publicKey); + x25519Wrapped = recipients.map((r, i) => { + try { + return aeadSeal(x25519Kek(eph.privateKey, r, ephemeralPub, r), cek, null); + } catch (e) { + if (e && e.code === 'PAP_X25519_LOW_ORDER') throw papError('PAP_BAD_KEY', `recipient key ${i} is a low-order point and cannot be sealed to`); + throw e; + } + }); + } + let passphraseStanza = null; + if (hasPassphrase) { + const salt = crypto.randomBytes(PASSPHRASE_SALT_LEN); + const kek = passphraseKek(passphrase, salt, rounds); + passphraseStanza = { salt, iterations: rounds, wrapped: aeadSeal(kek, cek, null) }; + } + + const keyCommit = keyCommitment(cek); + const bodyEncryptionKey = bodyKey(cek); + let used = false; + return Object.freeze({ + keyCommit, + ephemeralPub, + x25519Wrapped, + passphrase: passphraseStanza, + encryptBody(plaintext, headerBytes) { + if (used) { + throw papError('PAP_SEALER_REUSED', 'this sealer already encrypted a body; the zero nonce is safe only once per key'); + } + used = true; + return aeadSeal(bodyEncryptionKey, plaintext, headerBytes); + }, + }); +} + +// ---------- signed messages ---------- + +/** + * The bytes the inner signature covers. Signing these with the chain key + * binds the plaintext spine and memoir URL to the exact header they were + * sealed under: signer, chain head, seq, timestamp, key commitment, and every + * recipient stanza. Re-encrypting or re-addressing the same plaintext changes + * the header, so the inner signature no longer verifies. + */ +function innerMessage(headerBytes, spineRaw, memoirUrlBytes) { + return Buffer.concat([DOMAIN_INNER, NUL, sha256(headerBytes), sha256(spineRaw), sha256(memoirUrlBytes)]); +} + +/** + * The bytes the v2 OUTER signature covers: a domain string, a NUL, then the + * whole manifest. v1 signs the bare manifest and stays that way; the domain + * string keeps a v2 signature from ever being valid as anything else, and is + * free now and impossible to add once the format is frozen. + */ +function outerMessage(manifestBytes) { + return Buffer.concat([DOMAIN_OUTER, NUL, manifestBytes]); +} + +export { + CEK_LEN, + GCM_TAG_LEN, + DEFAULT_PBKDF2_ITERATIONS, + MIN_PBKDF2_ITERATIONS, + MAX_PBKDF2_ITERATIONS, + createSealer, + commitmentMatches, + aeadOpen, + openBody, + x25519RawPublic, + generateRecipientKeypair, + parseRecipientPublicKey, + parseRecipientPrivateKey, + x25519Unwrap, + passphraseKek, + innerMessage, + outerMessage, +}; diff --git a/test-data/pap-vectors/make-vectors.mjs b/test-data/pap-vectors/make-vectors.mjs new file mode 100644 index 0000000..b62388a --- /dev/null +++ b/test-data/pap-vectors/make-vectors.mjs @@ -0,0 +1,186 @@ +// Generate the shared PAP test vectors: the same bundle bytes are checked by +// node (test/pap-sealed.test.js) and by the offline reader (web/reader/). +// +// Run from the repo root: node test-data/pap-vectors/make-vectors.mjs [v1|sealed] +// +// v1 writes v1-plain.bin with the pack() that is on main. It was run +// once, before sealed mode existed, so the committed bytes are a +// bundle "from before this change". Re-running it must reproduce +// them byte for byte (Ed25519 and brotli are both deterministic). +// sealed writes the v2 sealed vectors with the current pack(). Sealing +// draws fresh randomness, so these are generated once and committed. +// negative appends two v2 bundles with valid outer signatures that a +// correct reader must still refuse: a re-addressed swap (inner +// binding) and a split key (key commitment); and a third whose +// outer signature lacks the v2 domain string, refused at the outer +// signature. Needs sealed mode. The low-level sealing pieces come +// from seal-primitives.mjs (test only), not from seal.js. +// +// Every key in vectors.json is a throwaway test key. None signs anything real. + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import * as packMod from '../../src/publish/pack.js'; + +const DIR = path.join(process.cwd(), 'test-data', 'pap-vectors'); +const JSON_PATH = path.join(DIR, 'vectors.json'); +const mode = process.argv[2] || 'v1'; + +const SPINE = [ + 'cartridge: test vector, not a real workflow', + 'step 1: read the board', + 'step 2: predict the next outcome before it happens', + 'step 3: sign the prediction, seal it, print the QR', + 'no em dashes, no secrets, nothing here is private', +].join('\n'); + +function rawPub(keyObj) { + return keyObj.export({ format: 'der', type: 'spki' }).subarray(-32); +} + +function loadOrInit() { + if (fs.existsSync(JSON_PATH)) return JSON.parse(fs.readFileSync(JSON_PATH, 'utf8')); + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519'); + return { + note: 'Throwaway test keys. Shared by test/pap-sealed.test.js and the reader tests.', + signer: { + privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }), + publicKeyHex: rawPub(publicKey).toString('hex'), + }, + spine: SPINE, + chainHeadHex: crypto.createHash('sha256').update('pap-vectors chain head').digest('hex'), + seq: 4242, + timestamp: 1793000000000, + vectors: {}, + }; +} + +const v = loadOrInit(); +const signingKey = crypto.createPrivateKey(v.signer.privateKeyPem); +const base = { + spine: v.spine, + chainHeadHash: Buffer.from(v.chainHeadHex, 'hex'), + chainPubkeyFp: Buffer.from(v.signer.publicKeyHex, 'hex'), + seq: v.seq, + timestamp: v.timestamp, + signingKey, +}; + +if (mode === 'v1') { + const { bundle } = packMod.pack({ ...base, memoirUrl: 'https://example.invalid/memoir' }); + fs.writeFileSync(path.join(DIR, 'v1-plain.bin'), bundle); + v.vectors['v1-plain'] = { + file: 'v1-plain.bin', + version: 1, + memoirUrl: 'https://example.invalid/memoir', + bytes: bundle.length, + sha256: crypto.createHash('sha256').update(bundle).digest('hex'), + }; +} else if (mode === 'sealed') { + if (typeof packMod.generateRecipientKeypair !== 'function') { + throw new Error('sealed mode is not in this checkout'); + } + const r1 = packMod.generateRecipientKeypair(); + const r2 = packMod.generateRecipientKeypair(); + const outsider = packMod.generateRecipientKeypair(); + v.recipients = { + r1: { privateKeyHex: r1.privateKeyRaw.toString('hex'), publicKeyHex: r1.publicKeyRaw.toString('hex') }, + r2: { privateKeyHex: r2.privateKeyRaw.toString('hex'), publicKeyHex: r2.publicKeyRaw.toString('hex') }, + outsider: { privateKeyHex: outsider.privateKeyRaw.toString('hex'), publicKeyHex: outsider.publicKeyRaw.toString('hex') }, + }; + v.passphrase = 'correct horse battery staple, test only'; + // The seal-time floor (docs/PAP-SEALED.md): pack() refuses below it, and + // there is no override. Real exports use the default, 600000. The count is + // carried in the bundle, so readers honour it. + v.passphraseIterations = 100000; + + const sealedTwo = packMod.pack({ + ...base, + memoirUrl: 'https://example.invalid/memoir', + seal: { to: [r1.publicKeyRaw, r2.publicKeyRaw] }, + }); + const sealedPass = packMod.pack({ + ...base, + seal: { passphrase: v.passphrase, iterations: v.passphraseIterations }, + }); + for (const [name, res, extra] of [ + ['v2-sealed-two-recipients', sealedTwo, { recipients: ['r1', 'r2'], memoirUrl: 'https://example.invalid/memoir' }], + ['v2-sealed-passphrase', sealedPass, { passphrase: true, memoirUrl: '' }], + ]) { + fs.writeFileSync(path.join(DIR, name + '.bin'), res.bundle); + v.vectors[name] = { + file: name + '.bin', + version: 2, + ...extra, + bytes: res.bundle.length, + sha256: crypto.createHash('sha256').update(res.bundle).digest('hex'), + }; + } +} else if (mode === 'negative') { + // Bundles a correct reader must refuse even though the outer signature is + // valid. Built from the committed sealed vector and keys, so every reader + // (node and the page) is tested on the same bytes. + // The low-level sealing pieces come from the test-only primitives: seal.js + // exports nothing that encrypts under a caller-supplied key. + const prim = await import('./seal-primitives.mjs'); + const manifest = await import('../../src/publish/manifest.js'); + const two = fs.readFileSync(path.join(DIR, v.vectors['v2-sealed-two-recipients'].file)); + const r1Priv = v.recipients.r1.privateKeyHex; + const { contentKey } = packMod.unpack({ bundle: two, recipientKey: r1Priv }); + const m = manifest.decodeSealedManifest(two.subarray(0, two.length - 64)); + const innerBytes = prim.aeadOpen(prim.bodyKey(contentKey), m.ciphertext, m.headerBytes); + const r2Pub = Buffer.from(v.recipients.r2.publicKeyHex, 'hex'); + const r1Pub = Buffer.from(v.recipients.r1.publicKeyHex, 'hex'); + + function sealedBundle(wraps, commitKey, bodyKeyCek, inner) { + const eph = crypto.generateKeyPairSync('x25519'); + const ephRaw = prim.x25519RawPublic(eph.publicKey); + const headerBytes = manifest.encodeSealedHeader({ + chainHeadHash: base.chainHeadHash, chainPubkeyFp: base.chainPubkeyFp, seq: base.seq, timestamp: base.timestamp, + keyCommit: prim.keyCommitment(commitKey), + ephemeralPub: ephRaw, + x25519Wrapped: wraps.map(([cek, pub]) => prim.x25519Wrap(cek, eph.privateKey, ephRaw, pub)), + passphrase: null, + }); + const body = typeof inner === 'function' ? inner(headerBytes) : inner; + const mb = manifest.encodeSealedManifest(headerBytes, prim.aeadSeal(prim.bodyKey(bodyKeyCek), body, headerBytes)); + return Buffer.concat([mb, prim.signOuter(mb, signingKey)]); + } + + // 1. The same inner bytes (inner signature included) re-addressed to r2 + // under a valid outer signature by the same key: inner binding fails. + const k1 = prim.generateContentKey(); + const readdressed = sealedBundle([[k1, r2Pub]], k1, k1, innerBytes); + // 2. r1's stanza wraps the committed key, r2's stanza a different one: + // r1 opens, r2 fails the key commitment. + const good = prim.generateContentKey(); + const other = prim.generateContentKey(); + const spineRaw = Buffer.from(v.spine, 'utf8'); + const equivocal = sealedBundle([[good, r1Pub], [other, r2Pub]], good, good, (headerBytes) => { + const sig = crypto.sign(null, prim.innerMessage(headerBytes, spineRaw, Buffer.alloc(0)), signingKey); + return Buffer.concat([sig, Buffer.from([0, 0]), innerBytes.subarray(64 + 2 + innerBytes.readUInt16BE(64))]); + }); + // 3. The committed two-recipient bundle with its outer signature made over + // the bare manifest, the way the construction worked before the outer + // domain string. Everything else is byte for byte the good vector. A + // correct reader refuses it at the outer signature. + const manifestBytes = two.subarray(0, two.length - 64); + const noDomain = Buffer.concat([manifestBytes, crypto.sign(null, manifestBytes, signingKey)]); + for (const [name, bytes, extra] of [ + ['v2-neg-readdressed', readdressed, { expect: { r2: 'PAP_INNER_BINDING_INVALID' } }], + ['v2-neg-key-commit', equivocal, { expect: { r1: 'opens', r2: 'PAP_KEY_COMMIT_MISMATCH' } }], + ['v2-neg-outer-no-domain', noDomain, { outerInvalid: true, expect: {} }], + ]) { + fs.writeFileSync(path.join(DIR, name + '.bin'), bytes); + v.vectors[name] = { + file: name + '.bin', version: 2, negative: true, ...extra, + bytes: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + }; + } +} else { + throw new Error('mode must be v1, sealed or negative'); +} + +fs.writeFileSync(JSON_PATH, JSON.stringify(v, null, 2) + '\n'); +console.log('wrote ' + Object.keys(v.vectors).join(', ')); diff --git a/test-data/pap-vectors/seal-primitives.mjs b/test-data/pap-vectors/seal-primitives.mjs new file mode 100644 index 0000000..0d244f8 --- /dev/null +++ b/test-data/pap-vectors/seal-primitives.mjs @@ -0,0 +1,103 @@ +// TEST-ONLY. Do not import this from src/ or bin/. +// +// An independent reimplementation of the sealing primitives of manifest v2 +// (docs/PAP-SEALED.md), written against node:crypto and nothing else. It +// exists for two reasons: +// +// 1. src/publish/seal.js no longer exports anything that encrypts under a +// caller-supplied key with the fixed zero nonce (the single-use sealer is +// the only way in). Tests and make-vectors.mjs still need to build +// hostile bundles by hand: a re-addressed swap, a split key, a weak KDF, +// a decompression bomb. They get the low-level pieces from here. +// 2. Because it shares no code with seal.js, a test that builds a bundle +// here and opens it with the production reader (or the reverse) checks +// the construction against the spec twice, not against itself. +// +// This file sits outside test/ on purpose: every .js under test/ is run by +// `node --test`, and this is a library, not a test. It enforces NO safety +// limits (no PBKDF2 floor, no single-use rule). That is its job, and the +// reason it must never ship in a bundle path. + +import crypto from 'node:crypto'; + +export const OUTER_DOMAIN = Buffer.from('lotor/pap/v2/outer', 'utf8'); +const NUL = Buffer.from([0]); +const ZERO_NONCE = Buffer.alloc(12); + +const sha256 = (...parts) => { + const h = crypto.createHash('sha256'); + for (const p of parts) h.update(p); + return h.digest(); +}; + +export function generateContentKey() { + return crypto.randomBytes(32); +} + +export function keyCommitment(cek) { + return sha256(Buffer.from('lotor/pap/v2/key-commit', 'utf8'), NUL, cek); +} + +export function bodyKey(cek) { + return Buffer.from(crypto.hkdfSync('sha256', cek, Buffer.alloc(0), 'lotor/pap/v2/body', 32)); +} + +/** AES-256-GCM with the fixed zero nonce. Callers must use each key once. */ +export function aeadSeal(key, plaintext, aad) { + const c = crypto.createCipheriv('aes-256-gcm', key, ZERO_NONCE); + if (aad) c.setAAD(aad); + return Buffer.concat([c.update(plaintext), c.final(), c.getAuthTag()]); +} + +export function aeadOpen(key, sealed, aad) { + const d = crypto.createDecipheriv('aes-256-gcm', key, ZERO_NONCE); + if (aad) d.setAAD(aad); + d.setAuthTag(sealed.subarray(sealed.length - 16)); + try { + return Buffer.concat([d.update(sealed.subarray(0, sealed.length - 16)), d.final()]); + } catch { + return null; + } +} + +export function x25519RawPublic(keyObj) { + return keyObj.export({ format: 'der', type: 'spki' }).subarray(-32); +} + +function x25519PublicFromRaw(raw) { + return crypto.createPublicKey({ + key: Buffer.concat([Buffer.from('302a300506032b656e032100', 'hex'), raw]), + format: 'der', + type: 'spki', + }); +} + +/** Wrap a content key to one recipient under an ephemeral key. */ +export function x25519Wrap(cek, ephPrivateKey, ephPubRaw, recipientPubRaw) { + const shared = crypto.diffieHellman({ privateKey: ephPrivateKey, publicKey: x25519PublicFromRaw(recipientPubRaw) }); + const kek = Buffer.from(crypto.hkdfSync( + 'sha256', shared, Buffer.concat([ephPubRaw, recipientPubRaw]), 'lotor/pap/v2/x25519-wrap', 32)); + return aeadSeal(kek, cek, null); +} + +/** Wrap a content key under a passphrase. No iteration floor here. */ +export function passphraseWrap(cek, passphrase, salt, iterations) { + const kek = crypto.pbkdf2Sync(Buffer.from(passphrase, 'utf8'), salt, iterations, 32, 'sha256'); + return aeadSeal(kek, cek, null); +} + +export function innerMessage(headerBytes, spineRaw, memoirUrlBytes) { + return Buffer.concat([ + Buffer.from('lotor/pap/v2/inner', 'utf8'), NUL, sha256(headerBytes), sha256(spineRaw), sha256(memoirUrlBytes), + ]); +} + +/** The bytes the v2 outer signature covers. */ +export function outerMessage(manifestBytes) { + return Buffer.concat([OUTER_DOMAIN, NUL, manifestBytes]); +} + +/** Sign a v2 manifest the way a conforming writer does. */ +export function signOuter(manifestBytes, signingKey) { + return crypto.sign(null, outerMessage(manifestBytes), signingKey); +} diff --git a/test-data/pap-vectors/v1-plain.bin b/test-data/pap-vectors/v1-plain.bin new file mode 100644 index 0000000..49da1fc Binary files /dev/null and b/test-data/pap-vectors/v1-plain.bin differ diff --git a/test-data/pap-vectors/v2-neg-key-commit.bin b/test-data/pap-vectors/v2-neg-key-commit.bin new file mode 100644 index 0000000..bbcabcd Binary files /dev/null and b/test-data/pap-vectors/v2-neg-key-commit.bin differ diff --git a/test-data/pap-vectors/v2-neg-outer-no-domain.bin b/test-data/pap-vectors/v2-neg-outer-no-domain.bin new file mode 100644 index 0000000..e937204 Binary files /dev/null and b/test-data/pap-vectors/v2-neg-outer-no-domain.bin differ diff --git a/test-data/pap-vectors/v2-neg-readdressed.bin b/test-data/pap-vectors/v2-neg-readdressed.bin new file mode 100644 index 0000000..5fa0495 Binary files /dev/null and b/test-data/pap-vectors/v2-neg-readdressed.bin differ diff --git a/test-data/pap-vectors/v2-sealed-passphrase.bin b/test-data/pap-vectors/v2-sealed-passphrase.bin new file mode 100644 index 0000000..9cab7e5 Binary files /dev/null and b/test-data/pap-vectors/v2-sealed-passphrase.bin differ diff --git a/test-data/pap-vectors/v2-sealed-two-recipients.bin b/test-data/pap-vectors/v2-sealed-two-recipients.bin new file mode 100644 index 0000000..f819e25 Binary files /dev/null and b/test-data/pap-vectors/v2-sealed-two-recipients.bin differ diff --git a/test-data/pap-vectors/vectors.json b/test-data/pap-vectors/vectors.json new file mode 100644 index 0000000..0590e91 --- /dev/null +++ b/test-data/pap-vectors/vectors.json @@ -0,0 +1,85 @@ +{ + "note": "Throwaway test keys. Shared by test/pap-sealed.test.js and the reader tests.", + "signer": { + "privateKeyPem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIM8bScLvpaAlwdER1X44k/BvLOY573A7OCP+ZYvsQTz7\n-----END PRIVATE KEY-----\n", + "publicKeyHex": "129f703d500122c8178f2538e0b1a9382effb0c5d37d6dee95cb5db656425aa7" + }, + "spine": "cartridge: test vector, not a real workflow\nstep 1: read the board\nstep 2: predict the next outcome before it happens\nstep 3: sign the prediction, seal it, print the QR\nno em dashes, no secrets, nothing here is private", + "chainHeadHex": "0b7a70d8e7eda08f69b48a66d0b302712f51f8e61b3effeec7792f150ce79a9a", + "seq": 4242, + "timestamp": 1793000000000, + "vectors": { + "v1-plain": { + "file": "v1-plain.bin", + "version": 1, + "memoirUrl": "https://example.invalid/memoir", + "bytes": 297, + "sha256": "6f96c791c87c0955310211b26c5a5883603fe33fbc61e1a3b68fa14081122f78" + }, + "v2-sealed-two-recipients": { + "file": "v2-sealed-two-recipients.bin", + "version": 2, + "recipients": [ + "r1", + "r2" + ], + "memoirUrl": "https://example.invalid/memoir", + "bytes": 539, + "sha256": "d11a03fda74dcfe014ecdbfbbb155725bfc4fb1b8874c467e1003e34cc14899b" + }, + "v2-sealed-passphrase": { + "file": "v2-sealed-passphrase.bin", + "version": 2, + "passphrase": true, + "memoirUrl": "", + "bytes": 449, + "sha256": "d21e632fbfd6c27a5f550d8401c33e4dfea696f9020ac55e2300ed0e323da9b2" + }, + "v2-neg-readdressed": { + "file": "v2-neg-readdressed.bin", + "version": 2, + "negative": true, + "expect": { + "r2": "PAP_INNER_BINDING_INVALID" + }, + "bytes": 491, + "sha256": "eae8579d997b8128c60910e6af09c57ae4ce4fa83752171ea74fab7723cbb8c7" + }, + "v2-neg-key-commit": { + "file": "v2-neg-key-commit.bin", + "version": 2, + "negative": true, + "expect": { + "r1": "opens", + "r2": "PAP_KEY_COMMIT_MISMATCH" + }, + "bytes": 509, + "sha256": "e6846b8906b6c106c8c884eaf7a88661e9137b67687cb68b77ad5e29095c8832" + }, + "v2-neg-outer-no-domain": { + "file": "v2-neg-outer-no-domain.bin", + "version": 2, + "negative": true, + "outerInvalid": true, + "expect": {}, + "bytes": 539, + "sha256": "7f58c62bf13475697778c0521a86eac8764bd8f776da7988c1462d9fdae3ef48" + } + }, + "recipients": { + "r1": { + "privateKeyHex": "c847ccd2eadb6448549f2c098043323373f9980f67c7c2ee6e4b7caa6da1e671", + "publicKeyHex": "a16eccea9ce3f1c9a201d37474b110d8a263ee2cc177da3fe77c45e24b49fa40" + }, + "r2": { + "privateKeyHex": "88fd719cd53bf8fc827bde7555a3ebc8190e7eac5b25ba912265cf1f3bb8cb41", + "publicKeyHex": "4600fc2626fd17a732613609913df4d8d148e7514ef62d2319d0b45bbb4ddb25" + }, + "outsider": { + "privateKeyHex": "107ddbf27bcc7ebdf49cc43f0c69db2623a13f7b78cf297f5b3af690f607ff62", + "publicKeyHex": "f0d39db2c4017b940367a69c0d6dd43ece2f09612555ae5c77d00fef6a20ba32" + } + }, + "passphrase": "correct horse battery staple, test only", + "passphraseIterations": 100000 +} diff --git a/test/pap-pack.test.js b/test/pap-pack.test.js index 7886a03..dc03917 100644 --- a/test/pap-pack.test.js +++ b/test/pap-pack.test.js @@ -140,10 +140,17 @@ test('tamper: bundle signed by a different key is rejected', () => { // Ask unpack to verify against a *different* public key. The bundle // itself is valid, but it wasn't signed by the key the reader is // checking against — that must be rejected. + // + // The error code depends on whether the sealed-mode patch has landed + // (proposals/pap-sealed.patch). On main today the signature check fails: + // PAP_SIGNATURE_INVALID. Once the patch lands, a supplied key must equal + // the fingerprint the bundle carries, and a different one is refused before + // any signature work: PAP_SIGNER_MISMATCH. Either way it is rejected, and + // the v1 bundle bytes are the same. const { publicKey: otherPublic } = generateKeypair(); assert.throws( () => unpack({ bundle, verifyKey: otherPublic }), - (err) => err.code === 'PAP_SIGNATURE_INVALID', + (err) => err.code === 'PAP_SIGNATURE_INVALID' || err.code === 'PAP_SIGNER_MISMATCH', 'bundle verified against a wrong key must be rejected', ); }); diff --git a/test/pap-sealed-docs.test.js b/test/pap-sealed-docs.test.js new file mode 100644 index 0000000..d2dec82 --- /dev/null +++ b/test/pap-sealed-docs.test.js @@ -0,0 +1,61 @@ +/** + * The claims in the sealed-PAP docs, pinned. Reads docs only, so it runs on + * main as well as on a patched tree. Added with the wringer review amendments + * (2026-10-26): the authorship claim had been written as stronger than the + * check can support, and the budget line items left out one operand. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const read = (rel) => fs.readFileSync(path.join(repoRoot, rel), 'utf8').replace(/\r\n/g, '\n').replace(/\s+/g, ' '); + +describe('sealed PAP docs state what verification can and cannot prove', () => { + it('PAP-SEALED.md: verification is self-consistency; authorship needs a fingerprint you trust', () => { + const doc = read('docs/PAP-SEALED.md'); + assert.match(doc, /unaltered and self-consistent with the key fingerprint it carries/); + assert.match(doc, /compare the fingerprint to one you already trust/); + assert.match(doc, /`self-consistent`/); + assert.match(doc, /`verified-against-supplied-key`/); + assert.match(doc, /PAP_SIGNER_MISMATCH/); + assert.doesNotMatch(doc, /without a key of their own/); + assert.doesNotMatch(doc, /was signed by the key whose fingerprint it carries/); + }); + + it('README: the PAP paragraph does not claim authorship from the signature alone', () => { + const readme = read('README.md'); + assert.match(readme, /self-consistency/); + assert.match(readme, /compare that fingerprint with one you already trust/); + assert.doesNotMatch(readme, /was signed by the key whose fingerprint it carries/); + }); + + it('PAP-SEALED.md: the budget section names the swapped u16 operand', () => { + const doc = read('docs/PAP-SEALED.md'); + assert.match(doc, /`ciphertextLen` takes the place of v1's `u16` spine-length tag/); + assert.match(doc, /sum to 194/); + }); + + it('PAP-SEALED.md: the layout and reader order carry the outer domain string, the cap and the floor', () => { + const doc = read('docs/PAP-SEALED.md'); + assert.match(doc, /"lotor\/pap\/v2\/outer" 0x00/); + assert.match(doc, /output cap of 4 MiB/); + assert.match(doc, /floor 100,000 at seal time/); + }); + + it('KNOWN-LIMITS 45 carries the passphrase-entropy and membership additions', () => { + const kl = read('KNOWN-LIMITS.md'); + const start = kl.indexOf('## 45. '); + const end = kl.indexOf('## 46. '); + assert.ok(start >= 0 && end > start, 'entry 45 not found'); + const e45 = kl.slice(start, end); + assert.match(e45, /Seal to keys when it matters\./, 'the existing words are still there'); + assert.match(e45, /not memory-hard/); + assert.match(e45, /real entropy/); + assert.match(e45, /recipient-membership oracle/); + assert.match(e45, /fewer than 100,000 rounds/); + }); +}); diff --git a/test/pap-sealed-patch.test.js b/test/pap-sealed-patch.test.js new file mode 100644 index 0000000..5f1f2c2 --- /dev/null +++ b/test/pap-sealed-patch.test.js @@ -0,0 +1,151 @@ +/** + * test/pap-sealed-patch.test.js + * + * STAGED, NOT YET LANDED. Sealed PAP bundles (WO-CARTRIDGE-SEAL part A) touch + * src/publish and bin/, which are core (src/grant/core-paths.js CORE_DIRS), + * so the change is queued as proposals/pap-sealed.patch for a signing sitting. + * This file proves the queued patch on temp copies: + * + * 1. it applies to today's source, one --include per target, the shape the + * sitting runs (KNOWN-LIMITS 82: every target named on the command line); + * 2. patched: test/pap-sealed.test.js passes with PAP_SEALED_REQUIRE=1, and + * the existing v1 suites (pap-pack, pap-qr) still pass; + * 3. unpatched control: the same sealed suite FAILS with PAP_SEALED_REQUIRE=1 + * (fail-first: the tests are not passing for a reason the patch does not + * supply). + * + * Copies live under os.tmpdir(); git runs only there. Nothing under src/ or + * bin/ in this checkout is written, and no git command runs on this repo. + * Once the patch lands, LANDED skips everything here and + * test/pap-sealed.test.js is the guard. Each skip is `return t.skip(...)`. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const LF = String.fromCharCode(10); +const CRLF = String.fromCharCode(13, 10); +const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PATCH = path.join(repoRoot, 'proposals', 'pap-sealed.patch'); +const LANDED = fs.existsSync(path.join(repoRoot, 'src', 'publish', 'seal.js')); +const TARGETS = [ + 'bin/pap-boot.js', + 'bin/pap-export.js', + 'bin/pap-keygen.js', + 'src/publish/manifest.js', + 'src/publish/pack.js', + 'src/publish/seal.js', +]; +const SKIP = 'patch landed; test/pap-sealed.test.js is the guard now'; + +function copyTree(fromRel, toRoot) { + const from = path.join(repoRoot, fromRel); + for (const name of fs.readdirSync(from)) { + const rel = path.join(fromRel, name); + const src = path.join(repoRoot, rel); + const dest = path.join(toRoot, rel); + if (fs.statSync(src).isDirectory()) { + copyTree(rel, toRoot); + } else { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + // LF-normalize text so the patch applies the same on an autocrlf checkout. + if (/[.](js|mjs|json)$/.test(name)) { + fs.writeFileSync(dest, fs.readFileSync(src, 'utf8').split(CRLF).join(LF)); + } else { + fs.copyFileSync(src, dest); + } + } + } +} + +function makeCopy(patched) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lotor-pap-sealed-patch-')); + fs.writeFileSync(path.join(tmp, 'package.json'), '{"type":"module"}' + LF); + copyTree('src', tmp); + copyTree('bin', tmp); + copyTree(path.join('test-data', 'pap-vectors'), tmp); + fs.mkdirSync(path.join(tmp, 'test')); + for (const f of ['pap-sealed.test.js', 'pap-pack.test.js', 'pap-qr.test.js']) { + fs.copyFileSync(path.join(repoRoot, 'test', f), path.join(tmp, 'test', f)); + } + fs.symlinkSync(path.join(repoRoot, 'node_modules'), path.join(tmp, 'node_modules'), 'junction'); + execFileSync('git', ['init', '-q'], { cwd: tmp }); + execFileSync('git', ['config', 'core.autocrlf', 'false'], { cwd: tmp }); + if (patched) { + fs.writeFileSync(path.join(tmp, 'pap-sealed.patch'), fs.readFileSync(PATCH, 'utf8').split(CRLF).join(LF)); + execFileSync('git', ['apply', ...TARGETS.map((p) => '--include=' + p), 'pap-sealed.patch'], { cwd: tmp }); + } + return tmp; +} + +function runSuites(root, files) { + // A child `node --test` that inherits NODE_TEST_CONTEXT refuses to run its + // files and exits 0 with a warning. Found by the control below on the first + // run: that is a silent pass, so the variable is dropped here. + const env = { ...process.env, PAP_SEALED_REQUIRE: '1' }; + delete env.NODE_TEST_CONTEXT; + const r = spawnSync(process.execPath, ['--test', '--test-reporter=tap', ...files], { + cwd: root, + encoding: 'utf8', + env, + }); + const count = (label) => { + const line = r.stdout.split(LF).find((l) => l.startsWith('# ' + label + ' ')); + return line ? Number(line.split(' ')[2]) : NaN; + }; + return { status: r.status, pass: count('pass'), fail: count('fail'), out: r.stdout + r.stderr }; +} + +function cleanup(tmp) { + try { + fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } catch { + // scratch space; a leftover dir is not a test failure + } +} + +describe('sealed PAP patch rehearsal (staged; proves the queued core patch)', () => { + it("the patch applies to today's source, every target named", (t) => { + if (LANDED) return t.skip(SKIP); + const tmp = makeCopy(true); + try { + for (const rel of TARGETS) { + assert.ok(fs.existsSync(path.join(tmp, ...rel.split('/'))), rel + ' missing after apply'); + } + assert.ok(fs.readFileSync(path.join(tmp, 'src', 'publish', 'pack.js'), 'utf8').includes('function packSealed(')); + } finally { + cleanup(tmp); + } + }); + + it('patched: the sealed suite passes and the v1 suites still pass', (t) => { + if (LANDED) return t.skip(SKIP); + const tmp = makeCopy(true); + try { + const r = runSuites(tmp, ['test/pap-sealed.test.js', 'test/pap-pack.test.js', 'test/pap-qr.test.js']); + assert.equal(r.fail, 0, r.out); + assert.equal(r.status, 0, r.out); + assert.ok(r.pass >= 42, 'expected at least 42 passing tests, got ' + r.pass + LF + r.out); + } finally { + cleanup(tmp); + } + }); + + it('unpatched control: the sealed suite fails without the patch (fail-first)', (t) => { + if (LANDED) return t.skip(SKIP); + const tmp = makeCopy(false); + try { + const r = runSuites(tmp, ['test/pap-sealed.test.js']); + assert.notEqual(r.status, 0, 'sealed suite passed with no patch: it proves nothing' + LF + r.out); + assert.equal(r.pass, 0, r.out); + assert.ok(r.fail >= 29, 'expected every sealed test to fail unpatched, got ' + r.fail); + } finally { + cleanup(tmp); + } + }); +}); diff --git a/test/pap-sealed.test.js b/test/pap-sealed.test.js new file mode 100644 index 0000000..6620038 --- /dev/null +++ b/test/pap-sealed.test.js @@ -0,0 +1,768 @@ +/** + * PAP sealed mode (manifest v2): encrypt-then-sign. WO-CARTRIDGE-SEAL part A. + * + * STAGED, NOT YET LANDED. Sealed mode is core (src/publish, bin/), queued as + * proposals/pap-sealed.patch for the signing sitting. Until it lands, every + * test here skips with `return t.skip(...)`, unless PAP_SEALED_REQUIRE=1, in + * which case a missing sealed API is a failure. test/pap-sealed-patch.test.js + * runs this file with PAP_SEALED_REQUIRE=1 on a temp copy twice: with the + * patch applied (must pass) and without it (must fail). That is the + * fail-first proof, and it runs inside every `npm test`. + * + * The four tests the WO names are marked WO-1 to WO-4. Each one that + * claims a defence also runs a control showing the same input is accepted + * when the defence's precondition is honestly met, so a pass cannot come from + * a construction that rejects everything. + * + * The tests marked R1 to R5 are the wringer review amendments (2026-10-26): + * trust accounting, the decompression cap and error hygiene, the PBKDF2 floor, + * the outer domain string, and the un-exported zero-nonce primitives. + * + * Hostile bundles are built with test-data/pap-vectors/seal-primitives.mjs, an + * independent test-only reimplementation: seal.js itself no longer exports + * anything that encrypts under a caller-supplied key. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const REQUIRE = process.env.PAP_SEALED_REQUIRE === '1'; + +const pack = await import('../src/publish/pack.js'); +const manifest = await import('../src/publish/manifest.js'); +const prim = await import('../test-data/pap-vectors/seal-primitives.mjs'); +let seal = null; +try { + seal = await import('../src/publish/seal.js'); +} catch { + seal = null; +} +const LANDED = seal !== null && typeof pack.inspect === 'function'; + +function gate(t) { + if (LANDED) return false; + if (REQUIRE) assert.fail('sealed mode is not in this tree (src/publish/seal.js or pack.inspect missing)'); + t.skip('sealed mode not landed; proposals/pap-sealed.patch, proved by test/pap-sealed-patch.test.js'); + return true; +} + +const SPINE = 'cartridge: predict the next outcome, sign it before it happens.\nstep 1: read the board.'; +// The seal-time PBKDF2 floor. Tests seal at the floor, never below it, because +// pack() refuses below it (R3). Hostile low-iteration bundles come from prim. +const FLOOR = 100000; +// The decode-side cap on a decompressed spine (R2): 4 MiB. +const CAP = 4 * 1024 * 1024; + +function author() { + const { privateKey, publicKey, publicKeyRaw } = pack.generateKeypair(); + return { privateKey, publicKey, publicKeyRaw }; +} + +function base(a, extra = {}) { + return { + spine: SPINE, + chainHeadHash: crypto.createHash('sha256').update('head').digest(), + chainPubkeyFp: a.publicKeyRaw, + seq: 77, + timestamp: 1793000000000, + signingKey: a.privateKey, + ...extra, + }; +} + +function codeOf(fn) { + try { + fn(); + } catch (err) { + return err.code || 'NO_CODE:' + err.message; + } + return 'NO_THROW'; +} + +function brotli(buf, quality = 5) { + return zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: quality } }); +} + +/** + * Rebuild a sealed bundle around existing inner bytes, the way an attacker + * holding a content key would: new content key, new recipients, the SAME + * inner plaintext (inner signature included), a valid outer signature by + * `signer`. With freshInnerFrom set, the inner signature is recomputed + * honestly by that key instead, which is the control. + */ +function reseal({ innerBytes, to, signer, signerRaw, chainHeadHash, seq, timestamp, freshInnerFrom }) { + const cek = prim.generateContentKey(); + const eph = crypto.generateKeyPairSync('x25519'); + const ephRaw = prim.x25519RawPublic(eph.publicKey); + const headerBytes = manifest.encodeSealedHeader({ + chainHeadHash, chainPubkeyFp: signerRaw, seq, timestamp, + keyCommit: prim.keyCommitment(cek), + ephemeralPub: ephRaw, + x25519Wrapped: to.map((r) => prim.x25519Wrap(cek, eph.privateKey, ephRaw, r)), + passphrase: null, + }); + let inner = innerBytes; + if (freshInnerFrom) { + const memoirLen = inner.readUInt16BE(64); + const memoir = inner.subarray(66, 66 + memoirLen); + const spineRaw = zlib.brotliDecompressSync(inner.subarray(66 + memoirLen)); + const sig = crypto.sign(null, prim.innerMessage(headerBytes, spineRaw, memoir), freshInnerFrom); + inner = Buffer.concat([sig, inner.subarray(64)]); + } + const ct = prim.aeadSeal(prim.bodyKey(cek), inner, headerBytes); + const m = manifest.encodeSealedManifest(headerBytes, ct); + return Buffer.concat([m, prim.signOuter(m, signer)]); +} + +/** + * Build a v2 bundle sealed to one recipient with a caller-chosen compressed + * spine, so the decode paths can be fed bytes pack() would never produce. The + * inner signature is honest over `spineRaw`; the outer is honest and carries + * the domain string unless noDomain is set. + */ +function handBuilt({ a, recipientPub, spineRaw, spineCompressed, iterations, passphrase, noDomain }) { + const cek = prim.generateContentKey(); + const eph = crypto.generateKeyPairSync('x25519'); + const ephRaw = prim.x25519RawPublic(eph.publicKey); + let pass = null; + if (passphrase !== undefined) { + const salt = crypto.randomBytes(16); + pass = { salt, iterations, wrapped: prim.passphraseWrap(cek, passphrase, salt, iterations) }; + } + const headerBytes = manifest.encodeSealedHeader({ + chainHeadHash: crypto.createHash('sha256').update('head').digest(), + chainPubkeyFp: a.publicKeyRaw, seq: 77, timestamp: 1793000000000, + keyCommit: prim.keyCommitment(cek), + ephemeralPub: recipientPub ? ephRaw : null, + x25519Wrapped: recipientPub ? [prim.x25519Wrap(cek, eph.privateKey, ephRaw, recipientPub)] : [], + passphrase: pass, + }); + const innerSig = crypto.sign(null, prim.innerMessage(headerBytes, spineRaw, Buffer.alloc(0)), a.privateKey); + const inner = Buffer.concat([innerSig, Buffer.from([0, 0]), spineCompressed]); + const m = manifest.encodeSealedManifest(headerBytes, prim.aeadSeal(prim.bodyKey(cek), inner, headerBytes)); + const sig = noDomain ? crypto.sign(null, m, a.privateKey) : prim.signOuter(m, a.privateKey); + return Buffer.concat([m, sig]); +} + +/** A v1 bundle around caller-chosen compressed bytes, signed honestly. */ +function handBuiltV1(a, spineCompressed) { + const m = manifest.encodeManifest({ + chainHeadHash: crypto.createHash('sha256').update('head').digest(), + chainPubkeyFp: a.publicKeyRaw, seq: 77, timestamp: 1793000000000, memoirUrl: '', spineCompressed, + }); + return Buffer.concat([m, crypto.sign(null, m, a.privateKey)]); +} + +/** Open a sealed bundle as a recipient and return its raw inner plaintext. */ +function innerOf(bundle, recipientKey) { + const { contentKey } = pack.unpack({ bundle, recipientKey }); + const m = manifest.decodeSealedManifest(bundle.subarray(0, bundle.length - 64)); + return prim.aeadOpen(prim.bodyKey(contentKey), m.ciphertext, m.headerBytes); +} + +describe('PAP sealed mode (v2)', () => { + it('round trip: one recipient opens it, byte-identical spine and fields', (t) => { + if (gate(t)) return; + const a = author(); + const r = pack.generateRecipientKeypair(); + const res = pack.pack(base(a, { memoirUrl: 'https://example.invalid/m', seal: { to: [r.publicKeyRaw] } })); + assert.equal(res.sealed, true); + assert.equal(res.bundle[0], 0x02, 'sealed bundles carry manifest version 2'); + const out = pack.unpack({ bundle: res.bundle, recipientKey: r.privateKey }); + assert.equal(out.spine, SPINE); + assert.equal(out.manifest.memoirUrl, 'https://example.invalid/m'); + assert.equal(out.manifest.seq, 77); + assert.equal(out.manifest.timestamp, 1793000000000); + assert.deepEqual(out.manifest.recipients, { x25519: 1, passphrase: false }); + assert.equal(Buffer.compare(out.manifest.chainPubkeyFp, a.publicKeyRaw), 0); + }); + + it('the spine is not in the bundle in the clear, compressed or raw', (t) => { + if (gate(t)) return; + const a = author(); + const r = pack.generateRecipientKeypair(); + const spine = 'a distinctive marker string that brotli will keep recognisable '.repeat(4); + const plain = pack.pack(base(a, { spine })); + const sealed = pack.pack(base(a, { spine, seal: { to: [r.publicKeyRaw] } })); + const compressed = plain.bundle.subarray(81 + 0, plain.bundle.length - 64); + assert.equal(sealed.bundle.includes(Buffer.from('distinctive marker')), false); + assert.equal(sealed.bundle.includes(compressed.subarray(0, 24)), false, 'brotli bytes of the spine leaked'); + }); + + it('outer verify needs no key: inspect() reports "sealed to N" before any key is given', (t) => { + if (gate(t)) return; + const a = author(); + const r1 = pack.generateRecipientKeypair(); + const r2 = pack.generateRecipientKeypair(); + const { bundle } = pack.pack(base(a, { seal: { to: [r1.publicKeyRaw, r2.publicKeyRaw] } })); + const { info } = pack.inspect({ bundle }); + assert.equal(info.sealed, true); + assert.deepEqual(info.recipients, { x25519: 2, passphrase: false }); + assert.equal(info.seq, 77); + assert.throws(() => pack.unpack({ bundle }), (err) => { + assert.equal(err.code, 'PAP_SEALED_NEEDS_KEY'); + assert.match(err.message, /verified, sealed to 2 recipient keys/); + assert.equal(err.info.recipients.x25519, 2); + return true; + }); + }); + + it('WO-1: flipping ANY byte of a sealed bundle fails the outer verify, with no key', (t) => { + if (gate(t)) return; + const a = author(); + const r = pack.generateRecipientKeypair(); + const { bundle } = pack.pack(base(a, { seal: { to: [r.publicKeyRaw] } })); + // Control: the untouched bundle verifies. + assert.equal(pack.inspect({ bundle }).info.sealed, true); + const m = manifest.decodeSealedManifest(bundle.subarray(0, bundle.length - 64)); + const ctStart = m.headerBytes.length + 2; + let ciphertextFlips = 0; + for (let pos = 0; pos < bundle.length; pos++) { + const tampered = Buffer.from(bundle); + tampered[pos] ^= 0x01; + assert.throws(() => pack.inspect({ bundle: tampered }), Error, `flip at ${pos} must be rejected`); + if (pos >= ctStart && pos < bundle.length - 64) { + assert.equal(codeOf(() => pack.inspect({ bundle: tampered })), 'PAP_SIGNATURE_INVALID', + `ciphertext flip at ${pos} must fail the signature, not some later check`); + ciphertextFlips++; + } + } + assert.equal(ciphertextFlips, m.ciphertext.length); + }); + + it('WO-2: the same plaintext re-encrypted to another recipient under a valid outer signature is rejected on the inner binding', (t) => { + if (gate(t)) return; + const alice = author(); + const mallory = author(); + const bob = pack.generateRecipientKeypair(); + const carol = pack.generateRecipientKeypair(); + const inputs = base(alice, { memoirUrl: 'https://example.invalid/m', seal: { to: [bob.publicKeyRaw] } }); + const { bundle } = pack.pack(inputs); + const innerBytes = innerOf(bundle, bob.privateKey); + const common = { innerBytes, to: [carol.publicKeyRaw], chainHeadHash: inputs.chainHeadHash, seq: 77, timestamp: inputs.timestamp }; + + // Bob re-addresses Alice's plaintext to Carol and signs the outer as himself (Mallory's key). + const resignedByOther = reseal({ ...common, signer: mallory.privateKey, signerRaw: mallory.publicKeyRaw }); + assert.equal(pack.inspect({ bundle: resignedByOther }).info.sealed, true, 'outer signature must be valid for the test to mean anything'); + assert.equal(codeOf(() => pack.unpack({ bundle: resignedByOther, recipientKey: carol.privateKey })), 'PAP_INNER_BINDING_INVALID'); + + // A holder of Alice's chain key re-addresses it under Alice's own outer signature. + const readdressed = reseal({ ...common, signer: alice.privateKey, signerRaw: alice.publicKeyRaw }); + assert.equal(pack.inspect({ bundle: readdressed }).info.sealed, true); + assert.equal(codeOf(() => pack.unpack({ bundle: readdressed, recipientKey: carol.privateKey })), 'PAP_INNER_BINDING_INVALID'); + + // A different chain head under the same key and recipients is a swap too. + const otherHead = reseal({ ...common, chainHeadHash: crypto.randomBytes(32), to: [bob.publicKeyRaw], signer: alice.privateKey, signerRaw: alice.publicKeyRaw }); + assert.equal(codeOf(() => pack.unpack({ bundle: otherHead, recipientKey: bob.privateKey })), 'PAP_INNER_BINDING_INVALID'); + + // Control: the same rebuild with an honest inner signature opens. So the + // rejections above come from the inner binding, not from reseal(). + const honest = reseal({ ...common, signer: alice.privateKey, signerRaw: alice.publicKeyRaw, freshInnerFrom: alice.privateKey }); + assert.equal(pack.unpack({ bundle: honest, recipientKey: carol.privateKey }).spine, SPINE); + }); + + it('WO-3: a wrong recipient key gets a clean, coded failure, never a crash', (t) => { + if (gate(t)) return; + const a = author(); + const r1 = pack.generateRecipientKeypair(); + const r2 = pack.generateRecipientKeypair(); + const outsider = pack.generateRecipientKeypair(); + const { bundle } = pack.pack(base(a, { seal: { to: [r1.publicKeyRaw, r2.publicKeyRaw] } })); + assert.equal(pack.unpack({ bundle, recipientKey: r1.privateKey }).spine, SPINE); + assert.equal(pack.unpack({ bundle, recipientKey: r2.privateKeyRaw.toString('hex') }).spine, SPINE); + assert.equal(codeOf(() => pack.unpack({ bundle, recipientKey: outsider.privateKey })), 'PAP_NOT_A_RECIPIENT'); + assert.equal(codeOf(() => pack.unpack({ bundle, recipientKey: outsider.privateKeyRaw })), 'PAP_NOT_A_RECIPIENT'); + assert.equal(codeOf(() => pack.unpack({ bundle, passphrase: 'not sealed to any passphrase' })), 'PAP_NOT_A_RECIPIENT'); + // An Ed25519 key in the X25519 slot is refused by type, not by crashing. + assert.throws(() => pack.unpack({ bundle, recipientKey: a.privateKey }), /expected x25519/); + }); + + it('passphrase: right one opens, wrong one is a clean PAP_NOT_A_RECIPIENT', (t) => { + if (gate(t)) return; + const a = author(); + const { bundle, recipients } = pack.pack(base(a, { seal: { passphrase: 'pass words here', iterations: FLOOR } })); + assert.deepEqual(recipients, { x25519: 0, passphrase: true }); + assert.equal(pack.unpack({ bundle, passphrase: 'pass words here' }).spine, SPINE); + assert.equal(codeOf(() => pack.unpack({ bundle, passphrase: 'pass words HERE' })), 'PAP_NOT_A_RECIPIENT'); + }); + + it('key commitment: an author cannot show two recipients two different plaintexts', (t) => { + if (gate(t)) return; + // Build the attack by hand: r1's stanza wraps the committed key, r2's + // stanza wraps a different key. Without the commitment check r2 would + // either fail opaquely or (with a crafted GCM body) read something else. + const a = author(); + const r1 = pack.generateRecipientKeypair(); + const r2 = pack.generateRecipientKeypair(); + const cek1 = prim.generateContentKey(); + const cek2 = prim.generateContentKey(); + const eph = crypto.generateKeyPairSync('x25519'); + const ephRaw = prim.x25519RawPublic(eph.publicKey); + const inputs = base(a); + const headerBytes = manifest.encodeSealedHeader({ + chainHeadHash: inputs.chainHeadHash, chainPubkeyFp: a.publicKeyRaw, seq: 77, timestamp: inputs.timestamp, + keyCommit: prim.keyCommitment(cek1), + ephemeralPub: ephRaw, + x25519Wrapped: [prim.x25519Wrap(cek1, eph.privateKey, ephRaw, r1.publicKeyRaw), prim.x25519Wrap(cek2, eph.privateKey, ephRaw, r2.publicKeyRaw)], + passphrase: null, + }); + const spineRaw = Buffer.from(SPINE, 'utf8'); + const innerSig = crypto.sign(null, prim.innerMessage(headerBytes, spineRaw, Buffer.alloc(0)), a.privateKey); + const inner = Buffer.concat([innerSig, Buffer.from([0, 0]), zlib.brotliCompressSync(spineRaw)]); + const m = manifest.encodeSealedManifest(headerBytes, prim.aeadSeal(prim.bodyKey(cek1), inner, headerBytes)); + const bundle = Buffer.concat([m, prim.signOuter(m, a.privateKey)]); + assert.equal(pack.unpack({ bundle, recipientKey: r1.privateKey }).spine, SPINE, 'control: the committed key opens'); + assert.equal(codeOf(() => pack.unpack({ bundle, recipientKey: r2.privateKey })), 'PAP_KEY_COMMIT_MISMATCH'); + }); + + it('reveal: the content key opens the bundle for anyone, and a wrong key fails the commitment', (t) => { + if (gate(t)) return; + const a = author(); + const r = pack.generateRecipientKeypair(); + const { bundle } = pack.pack(base(a, { seal: { to: [r.publicKeyRaw] } })); + const { contentKey } = pack.unpack({ bundle, recipientKey: r.privateKey }); + assert.equal(pack.unpack({ bundle, contentKey: contentKey.toString('hex') }).spine, SPINE); + assert.equal(codeOf(() => pack.unpack({ bundle, contentKey: crypto.randomBytes(32) })), 'PAP_KEY_COMMIT_MISMATCH'); + }); + + it('refuses a seal with no recipients or a duplicated recipient', (t) => { + if (gate(t)) return; + const a = author(); + const r = pack.generateRecipientKeypair(); + assert.throws(() => pack.pack(base(a, { seal: {} })), /at least one recipient/); + assert.throws(() => pack.pack(base(a, { seal: { to: [r.publicKeyRaw, r.publicKeyRaw.toString('hex')] } })), /same recipient twice/); + }); + + it('budget: overhead is measured and pinned (194 B for one key, +48 B per extra key, 182 B for a passphrase, 262 B for both)', (t) => { + if (gate(t)) return; + const a = author(); + const keys = Array.from({ length: 5 }, () => pack.generateRecipientKeypair().publicKeyRaw); + const plain = pack.pack(base(a)); + for (let n = 1; n <= 5; n++) { + const res = pack.pack(base(a, { seal: { to: keys.slice(0, n) } })); + assert.equal(res.sealOverhead, 194 + 48 * (n - 1), `overhead for ${n} recipient(s)`); + assert.equal(res.totalLen, plain.totalLen + res.sealOverhead); + } + const pass = pack.pack(base(a, { seal: { passphrase: 'x', iterations: FLOOR } })); + assert.equal(pass.sealOverhead, 182); + const both = pack.pack(base(a, { seal: { to: [keys[0]], passphrase: 'x', iterations: FLOOR } })); + assert.equal(both.sealOverhead, 262); + }); + + it('budget: pack() still refuses over the 2,953 B cap, and says what sealing cost', (t) => { + if (gate(t)) return; + const a = author(); + const r = pack.generateRecipientKeypair(); + // Find a spine that fits plain but not sealed: the refusal must come from sealing. + // Random base64 compresses to about 3/4 of its length, so each step adds + // roughly 30 B: from over 100 B of headroom it cannot overshoot the cap. + let spine = crypto.randomBytes(1500).toString('base64'); + while (pack.pack(base(a, { spine })).headroom > 100) spine += crypto.randomBytes(30).toString('base64'); + assert.ok(pack.pack(base(a, { spine })).totalLen <= manifest.QR_V40_BINARY_ECL_CAPACITY); + assert.throws(() => pack.pack(base(a, { spine, seal: { to: [r.publicKeyRaw] } })), (err) => { + assert.equal(err.code, 'PAP_BUDGET_EXCEEDED'); + assert.equal(err.sealOverhead, 194); + assert.ok(err.totalLen > err.budget); + assert.match(err.message, /sealing costs 194 bytes/); + return true; + }); + }); + + it('WO-4: a plaintext v1 bundle made before this change still verifies, and pack() still makes it byte for byte', (t) => { + if (gate(t)) return; + const v = JSON.parse(fs.readFileSync(path.join(repoRoot, 'test-data', 'pap-vectors', 'vectors.json'), 'utf8')); + const vec = v.vectors['v1-plain']; + const bytes = fs.readFileSync(path.join(repoRoot, 'test-data', 'pap-vectors', vec.file)); + assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'), vec.sha256, 'vector file changed'); + const out = pack.unpack({ bundle: bytes }); + assert.equal(out.spine, v.spine); + assert.equal(out.manifest.memoirUrl, vec.memoirUrl); + assert.equal(pack.inspect({ bundle: bytes }).info.sealed, false); + const again = pack.pack({ + spine: v.spine, + chainHeadHash: Buffer.from(v.chainHeadHex, 'hex'), + chainPubkeyFp: Buffer.from(v.signer.publicKeyHex, 'hex'), + seq: v.seq, + timestamp: v.timestamp, + memoirUrl: vec.memoirUrl, + signingKey: crypto.createPrivateKey(v.signer.privateKeyPem), + }); + assert.equal(Buffer.compare(again.bundle, bytes), 0, 'v1 pack() output moved'); + }); + + it('shared vectors: the committed sealed bundles open with their keys and refuse an outsider', (t) => { + if (gate(t)) return; + const dir = path.join(repoRoot, 'test-data', 'pap-vectors'); + const v = JSON.parse(fs.readFileSync(path.join(dir, 'vectors.json'), 'utf8')); + const two = fs.readFileSync(path.join(dir, v.vectors['v2-sealed-two-recipients'].file)); + for (const who of ['r1', 'r2']) { + assert.equal(pack.unpack({ bundle: two, recipientKey: v.recipients[who].privateKeyHex }).spine, v.spine); + } + assert.equal(codeOf(() => pack.unpack({ bundle: two, recipientKey: v.recipients.outsider.privateKeyHex })), 'PAP_NOT_A_RECIPIENT'); + const pass = fs.readFileSync(path.join(dir, v.vectors['v2-sealed-passphrase'].file)); + assert.equal(pack.unpack({ bundle: pass, passphrase: v.passphrase }).spine, v.spine); + assert.ok(v.passphraseIterations >= FLOOR, 'the passphrase vector must be sealed at or above the floor'); + }); + + it('shared negative vectors: valid outer signature, refused on the inner binding and the key commitment; an outer signature without the domain string is refused outright', (t) => { + if (gate(t)) return; + const dir = path.join(repoRoot, 'test-data', 'pap-vectors'); + const v = JSON.parse(fs.readFileSync(path.join(dir, 'vectors.json'), 'utf8')); + for (const name of ['v2-neg-readdressed', 'v2-neg-key-commit', 'v2-neg-outer-no-domain']) { + const vec = v.vectors[name]; + const bundle = fs.readFileSync(path.join(dir, vec.file)); + assert.equal(crypto.createHash('sha256').update(bundle).digest('hex'), vec.sha256, name + ' file changed'); + if (vec.outerInvalid) { + assert.equal(codeOf(() => pack.inspect({ bundle })), 'PAP_SIGNATURE_INVALID', name + ': the outer signature must be refused'); + continue; + } + assert.equal(pack.inspect({ bundle }).info.sealed, true, name + ': the outer signature must verify'); + for (const [who, want] of Object.entries(vec.expect)) { + const key = v.recipients[who].privateKeyHex; + if (want === 'opens') assert.equal(pack.unpack({ bundle, recipientKey: key }).spine, v.spine); + else assert.equal(codeOf(() => pack.unpack({ bundle, recipientKey: key })), want, `${name} as ${who}`); + } + } + }); +}); + +describe('PAP sealed mode: wringer review amendments (R1 to R5)', () => { + it('R1: trust is a distinct field; a supplied key must equal the embedded fingerprint (v1 and v2)', (t) => { + if (gate(t)) return; + const a = author(); + const other = author(); + const r = pack.generateRecipientKeypair(); + const cases = [ + ['v1', pack.pack(base(a)).bundle, {}], + ['v2', pack.pack(base(a, { seal: { to: [r.publicKeyRaw] } })).bundle, { recipientKey: r.privateKey }], + ]; + for (const [label, bundle, open] of cases) { + assert.equal(pack.inspect({ bundle }).info.trust, 'self-consistent', label + ' inspect, no key'); + assert.equal(pack.inspect({ bundle, verifyKey: a.publicKey }).info.trust, 'verified-against-supplied-key', label + ' inspect, key'); + assert.equal(pack.unpack({ bundle, ...open }).trust, 'self-consistent', label + ' unpack, no key'); + assert.equal(pack.unpack({ bundle, verifyKey: a.publicKey, ...open }).trust, 'verified-against-supplied-key', label + ' unpack, key'); + + // A supplied key that is not the embedded fingerprint is refused as a + // signer mismatch, not as a signature failure. + assert.equal(codeOf(() => pack.inspect({ bundle, verifyKey: other.publicKey })), 'PAP_SIGNER_MISMATCH', label + ' inspect'); + assert.equal(codeOf(() => pack.unpack({ bundle, verifyKey: other.publicKey, ...open })), 'PAP_SIGNER_MISMATCH', label + ' unpack'); + // It is checked BEFORE any signature work: a damaged signature plus a + // wrong key reports the mismatch, while the damaged bundle alone reports the signature. + const damaged = Buffer.from(bundle); + damaged[damaged.length - 1] ^= 1; + assert.equal(codeOf(() => pack.inspect({ bundle: damaged, verifyKey: other.publicKey })), 'PAP_SIGNER_MISMATCH', label + ' order'); + assert.equal(codeOf(() => pack.inspect({ bundle: damaged })), 'PAP_SIGNATURE_INVALID', label + ' control'); + // A key of another type can never equal an Ed25519 fingerprint. + assert.equal(codeOf(() => pack.inspect({ bundle, verifyKey: r.publicKey })), 'PAP_SIGNER_MISMATCH', label + ' x25519 key'); + } + }); + + it('R1: "signed by someone else but carrying my fingerprint" is refused, and the message claims only what the check can know', (t) => { + if (gate(t)) return; + const a = author(); + const b = author(); + const r = pack.generateRecipientKeypair(); + for (const extra of [{}, { seal: { to: [r.publicKeyRaw] } }]) { + // B signs a bundle whose fingerprint field says A. + const forged = pack.pack(base(a, { ...extra, signingKey: b.privateKey })).bundle; + assert.throws(() => pack.inspect({ bundle: forged }), (err) => { + assert.equal(err.code, 'PAP_SIGNATURE_INVALID'); + assert.match(err.message, /does not verify under the key whose fingerprint it carries/); + assert.doesNotMatch(err.message, /different key/); + return true; + }); + // Control: B signing as B is self-consistent, and says nothing about A. + const asB = pack.pack(base(b, { ...extra, signingKey: b.privateKey })).bundle; + const { info } = pack.inspect({ bundle: asB }); + assert.equal(info.trust, 'self-consistent'); + assert.equal(Buffer.compare(info.chainPubkeyFp, b.publicKeyRaw), 0); + } + }); + + it('R2: a brotli bomb is refused with PAP_SPINE_TOO_LARGE, malformed brotli with PAP_DECOMPRESS_FAILED (v1 and v2)', (t) => { + if (gate(t)) return; + const a = author(); + const r = pack.generateRecipientKeypair(); + const bombRaw = Buffer.alloc(2 * CAP); + const bomb = brotli(bombRaw); + assert.ok(bomb.length < 1024, 'the bomb must be tiny on the wire, got ' + bomb.length); + const garbage = Buffer.alloc(48, 0xff); + + // v1: valid signature, hostile payload. + assert.equal(codeOf(() => pack.unpack({ bundle: handBuiltV1(a, bomb) })), 'PAP_SPINE_TOO_LARGE', 'v1 bomb'); + assert.equal(codeOf(() => pack.unpack({ bundle: handBuiltV1(a, garbage) })), 'PAP_DECOMPRESS_FAILED', 'v1 garbage'); + const okRaw = Buffer.alloc(CAP / 4, 0x61); + const okV1 = pack.unpack({ bundle: handBuiltV1(a, brotli(okRaw)) }); + assert.equal(okV1.spineRawLen, okRaw.length, 'control: a large spine under the cap still opens'); + + // v2: valid outer and inner signatures, hostile payload, opened by the recipient. + const v2 = (spineRaw, spineCompressed) => handBuilt({ a, recipientPub: r.publicKeyRaw, spineRaw, spineCompressed }); + assert.equal(codeOf(() => pack.unpack({ bundle: v2(bombRaw, bomb), recipientKey: r.privateKey })), 'PAP_SPINE_TOO_LARGE', 'v2 bomb'); + assert.equal(codeOf(() => pack.unpack({ bundle: v2(Buffer.from('x'), garbage), recipientKey: r.privateKey })), 'PAP_DECOMPRESS_FAILED', 'v2 garbage'); + const okV2 = pack.unpack({ bundle: v2(okRaw, brotli(okRaw)), recipientKey: r.privateKey }); + assert.equal(okV2.spineRawLen, okRaw.length, 'control: the hand-built v2 bundle opens when the payload is sane'); + assert.equal(pack.MAX_SPINE_RAW_BYTES, CAP, 'the cap is the named 4 MiB constant'); + }); + + it('R2: x25519Unwrap returns null only for the low-order refusal; anything else is a coded PAP_BAD_KEY', (t) => { + if (gate(t)) return; + const a = author(); + const r = pack.generateRecipientKeypair(); + const { bundle } = pack.pack(base(a, { seal: { to: [r.publicKeyRaw] } })); + const m = manifest.decodeSealedManifest(bundle.subarray(0, bundle.length - 64)); + const w = m.x25519Wrapped[0]; + const cek = seal.x25519Unwrap(w, r.privateKey, m.ephemeralPub); + assert.equal(cek.length, 32, 'control: the right key unwraps'); + const other = pack.generateRecipientKeypair(); + assert.equal(seal.x25519Unwrap(w, other.privateKey, m.ephemeralPub), null, 'a stanza for someone else is null'); + // A low-order ephemeral key makes an all-zero shared secret: refused, so "not this stanza". + assert.equal(seal.x25519Unwrap(w, r.privateKey, Buffer.alloc(32)), null, 'low-order peer is null'); + // A malformed ephemeral key is a bad key, not a quiet "not a recipient". + assert.equal(codeOf(() => seal.x25519Unwrap(w, r.privateKey, Buffer.alloc(31, 1))), 'PAP_BAD_KEY', 'short ephemeral key'); + // A private key of the wrong type is a bad key. + assert.equal(codeOf(() => seal.x25519Unwrap(w, a.privateKey, m.ephemeralPub)), 'PAP_BAD_KEY', 'ed25519 key in the x25519 slot'); + }); + + it('R2: a content key that is not 32 bytes / 64 hex chars is PAP_BAD_CONTENT_KEY, not a commitment mismatch', (t) => { + if (gate(t)) return; + const a = author(); + const r = pack.generateRecipientKeypair(); + const { bundle } = pack.pack(base(a, { seal: { to: [r.publicKeyRaw] } })); + for (const bad of ['zz'.repeat(32), 'ab'.repeat(31), 'ab'.repeat(33), '', Buffer.alloc(31), Buffer.alloc(33)]) { + assert.equal(codeOf(() => pack.unpack({ bundle, contentKey: bad })), 'PAP_BAD_CONTENT_KEY', JSON.stringify(String(bad)).slice(0, 20)); + } + // A well-formed but wrong key is still the commitment failure. + assert.equal(codeOf(() => pack.unpack({ bundle, contentKey: 'ab'.repeat(32) })), 'PAP_KEY_COMMIT_MISMATCH'); + }); + + it('R2: a malformed recipient key is a coded PAP_BAD_KEY', (t) => { + if (gate(t)) return; + const a = author(); + const r = pack.generateRecipientKeypair(); + const { bundle } = pack.pack(base(a, { seal: { to: [r.publicKeyRaw] } })); + assert.equal(codeOf(() => pack.unpack({ bundle, recipientKey: 'not a key at all' })), 'PAP_BAD_KEY'); + assert.equal(codeOf(() => pack.unpack({ bundle, recipientKey: Buffer.alloc(7) })), 'PAP_BAD_KEY'); + assert.equal(codeOf(() => pack.pack(base(a, { seal: { to: ['definitely not a public key'] } }))), 'PAP_BAD_KEY'); + }); + + it('R3: sealing below the PBKDF2 floor is refused; the default stays 600000; the decode side still opens older counts', (t) => { + if (gate(t)) return; + const a = author(); + for (const iterations of [1, 1000, FLOOR - 1]) { + assert.equal(codeOf(() => pack.pack(base(a, { seal: { passphrase: 'pw', iterations } }))), 'PAP_KDF_TOO_WEAK', 'iterations ' + iterations); + } + const atFloor = pack.pack(base(a, { seal: { passphrase: 'pw', iterations: FLOOR } })); + const dec = manifest.decodeSealedManifest(atFloor.bundle.subarray(0, atFloor.bundle.length - 64)); + assert.equal(dec.passphrase.iterations, FLOOR, 'control: the floor itself is allowed'); + const dflt = pack.pack(base(a, { seal: { passphrase: 'pw' } })); + assert.equal(manifest.decodeSealedManifest(dflt.bundle.subarray(0, dflt.bundle.length - 64)).passphrase.iterations, 600000); + + // Opening is not floored: a bundle some other writer sealed at 1000 rounds + // is still readable by the holder of the passphrase (the test-only + // primitives have no floor). The reader's cap on the high side is separate. + const weak = handBuilt({ + a, spineRaw: Buffer.from(SPINE), spineCompressed: brotli(Buffer.from(SPINE)), iterations: 1000, passphrase: 'old pw', + }); + assert.equal(pack.unpack({ bundle: weak, passphrase: 'old pw' }).spine, SPINE); + assert.equal(seal.MIN_PBKDF2_ITERATIONS, FLOOR, 'the floor is the named 100000 constant'); + assert.equal(seal.DEFAULT_PBKDF2_ITERATIONS, 600000); + }); + + it('R4: the v2 outer signature covers "lotor/pap/v2/outer" 0x00 || manifest, and a raw-manifest signature is refused', (t) => { + if (gate(t)) return; + const a = author(); + const r = pack.generateRecipientKeypair(); + const { bundle } = pack.pack(base(a, { seal: { to: [r.publicKeyRaw] } })); + const manifestBytes = bundle.subarray(0, bundle.length - 64); + const sig = bundle.subarray(bundle.length - 64); + const domain = Buffer.concat([Buffer.from('lotor/pap/v2/outer', 'utf8'), Buffer.from([0]), manifestBytes]); + assert.equal(crypto.verify(null, domain, a.publicKey, sig), true, 'signed over the domain-separated message'); + assert.equal(crypto.verify(null, manifestBytes, a.publicKey, sig), false, 'not over the raw manifest'); + assert.equal(Buffer.compare(domain, prim.outerMessage(manifestBytes)), 0, 'the independent primitive agrees'); + + // The old construction (outer signature over the bare manifest) no longer verifies. + const oldStyle = handBuilt({ + a, recipientPub: r.publicKeyRaw, spineRaw: Buffer.from(SPINE), spineCompressed: brotli(Buffer.from(SPINE)), noDomain: true, + }); + assert.equal(codeOf(() => pack.inspect({ bundle: oldStyle })), 'PAP_SIGNATURE_INVALID'); + // Control: the same hand-built bundle with the domain string verifies and opens. + const withDomain = handBuilt({ + a, recipientPub: r.publicKeyRaw, spineRaw: Buffer.from(SPINE), spineCompressed: brotli(Buffer.from(SPINE)), + }); + assert.equal(pack.unpack({ bundle: withDomain, recipientKey: r.privateKey }).spine, SPINE); + // A v1 decoder never misreads a v2 manifest: it stops on the version byte. + assert.throws(() => manifest.decodeManifest(Buffer.from(manifestBytes)), /unknown manifest version: 0x2/); + }); + + it('R5: nothing importable encrypts under a caller-supplied key with the zero nonce', (t) => { + if (gate(t)) return; + const forbidden = ['aeadSeal', 'bodyKey', 'generateContentKey', 'x25519Wrap', 'x25519Kek', 'passphraseWrap']; + for (const [file, mod] of [['seal.js', seal], ['pack.js', pack]]) { + for (const name of forbidden) assert.equal(name in mod, false, `${file} must not export ${name}`); + } + // The exact surfaces, so a new export is a conscious change. + assert.deepEqual(Object.keys(seal).sort(), [ + 'CEK_LEN', 'DEFAULT_PBKDF2_ITERATIONS', 'GCM_TAG_LEN', 'MAX_PBKDF2_ITERATIONS', 'MIN_PBKDF2_ITERATIONS', + 'aeadOpen', 'commitmentMatches', 'createSealer', 'generateRecipientKeypair', 'innerMessage', + 'openBody', 'outerMessage', 'parseRecipientPrivateKey', 'parseRecipientPublicKey', 'passphraseKek', + 'x25519RawPublic', 'x25519Unwrap', + ].sort()); + assert.deepEqual(Object.keys(pack).sort(), [ + 'MAX_SPINE_RAW_BYTES', 'generateKeypair', 'generateRecipientKeypair', 'inspect', 'pack', 'unpack', + ]); + }); + + it('R5: the single-use sealer keeps the content key inside, and refuses a second body', (t) => { + if (gate(t)) return; + const r = pack.generateRecipientKeypair(); + const sealer = seal.createSealer({ to: [r.publicKeyRaw], passphrase: 'pw', iterations: FLOOR }); + assert.deepEqual(Object.keys(sealer).sort(), ['encryptBody', 'ephemeralPub', 'keyCommit', 'passphrase', 'x25519Wrapped']); + for (const v of Object.values(sealer)) { + assert.notEqual(Buffer.isBuffer(v) && v.length === 32 && prim.keyCommitment(v).equals(sealer.keyCommit), true, 'the content key must not be a property'); + } + const header = Buffer.from('header bytes'); + const body = Buffer.from('inner bytes'); + const ct = sealer.encryptBody(body, header); + assert.equal(codeOf(() => sealer.encryptBody(body, header)), 'PAP_SEALER_REUSED'); + assert.equal(codeOf(() => sealer.encryptBody(Buffer.from('another'), header)), 'PAP_SEALER_REUSED'); + // Cross-check against the independent primitives: the stanza unwraps to a + // key that matches the commitment and opens the body. + const cek = seal.x25519Unwrap(sealer.x25519Wrapped[0], r.privateKey, sealer.ephemeralPub); + assert.equal(Buffer.compare(prim.keyCommitment(cek), sealer.keyCommit), 0); + assert.equal(Buffer.compare(prim.aeadOpen(prim.bodyKey(cek), ct, header), body), 0); + // And a second sealer makes a different key, so the zero nonce is never shared. + const sealer2 = seal.createSealer({ to: [r.publicKeyRaw] }); + assert.notEqual(Buffer.compare(sealer2.keyCommit, sealer.keyCommit), 0); + }); +}); + +describe('PAP sealed mode: the CLIs', () => { + function run(script, args, env = {}) { + return spawnSync(process.execPath, [path.join(repoRoot, 'bin', script), ...args], { + encoding: 'utf8', env: { ...process.env, ...env }, + }); + } + + function fixture(tmp) { + const a = crypto.generateKeyPairSync('ed25519'); + const keyPem = path.join(tmp, 'author.pem'); + fs.writeFileSync(keyPem, a.privateKey.export({ format: 'pem', type: 'pkcs8' })); + const pubPem = path.join(tmp, 'author.pub.pem'); + fs.writeFileSync(pubPem, a.publicKey.export({ format: 'pem', type: 'spki' })); + const fp = a.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex'); + const spineFile = path.join(tmp, 'spine.txt'); + fs.writeFileSync(spineFile, SPINE); + assert.equal(run('pap-keygen.js', ['--out', path.join(tmp, 'bob')]).status, 0); + return { a, keyPem, pubPem, fp, spineFile }; + } + + function exportArgs(f, tmp, extra = []) { + return [ + '--spine', f.spineFile, '--public', '--seal', '--to', path.join(tmp, 'bob.pub'), + '--key', f.keyPem, '--chain-head', 'ab'.repeat(32), '--chain-pubkey-fp', f.fp, '--seq', '9', '--timestamp', '1793000000000', + '--out', path.join(tmp, 'c'), ...extra, + ]; + } + + it('pap-keygen, pap-export --seal, pap-boot: exit codes 5 (no key), 0 (key), 6 (wrong key)', (t) => { + if (gate(t)) return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lotor-pap-sealed-cli-')); + try { + const f = fixture(tmp); + assert.equal(run('pap-keygen.js', ['--out', path.join(tmp, 'eve')]).status, 0); + assert.notEqual(run('pap-keygen.js', ['--out', path.join(tmp, 'bob')]).status, 0, 'keygen must not overwrite'); + + const exp = run('pap-export.js', exportArgs(f, tmp)); + assert.equal(exp.status, 0, exp.stderr); + assert.match(exp.stdout, /sealed to 1 recipient key\(s\) \(sealing cost 194 B\)/); + + const noKey = run('pap-boot.js', ['--bundle', path.join(tmp, 'c.bin')]); + assert.equal(noKey.status, 5, noKey.stderr); + assert.equal(noKey.stdout, ''); + assert.match(noKey.stderr, /verified, sealed to 1 recipient key/); + + const ok = run('pap-boot.js', ['--bundle', path.join(tmp, 'c.bin'), '--recipient-key', path.join(tmp, 'bob.key')]); + assert.equal(ok.status, 0, ok.stderr); + assert.equal(ok.stdout, SPINE); + + const wrong = run('pap-boot.js', ['--bundle', path.join(tmp, 'c.bin'), '--recipient-key', path.join(tmp, 'eve.key')]); + assert.equal(wrong.status, 6, wrong.stderr); + assert.equal(wrong.stdout, ''); + + const tampered = fs.readFileSync(path.join(tmp, 'c.bin')); + tampered[tampered.length - 100] ^= 1; + fs.writeFileSync(path.join(tmp, 'bad.bin'), tampered); + assert.equal(run('pap-boot.js', ['--bundle', path.join(tmp, 'bad.bin')]).status, 4); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('R1: pap-boot prints its trust basis, and a --verify-key that is not the embedded signer exits 4', (t) => { + if (gate(t)) return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lotor-pap-sealed-cli-')); + try { + const f = fixture(tmp); + assert.equal(run('pap-export.js', exportArgs(f, tmp)).status, 0); + const bundle = path.join(tmp, 'c.bin'); + const bob = path.join(tmp, 'bob.key'); + + const self = run('pap-boot.js', ['--bundle', bundle, '--recipient-key', bob]); + assert.equal(self.status, 0, self.stderr); + assert.match(self.stderr, /trust self-consistent/); + assert.match(self.stderr, /compare the fingerprint/); + + const noKey = run('pap-boot.js', ['--bundle', bundle]); + assert.equal(noKey.status, 5); + assert.match(noKey.stderr, /trust self-consistent/); + + const supplied = run('pap-boot.js', ['--bundle', bundle, '--recipient-key', bob, '--verify-key', f.pubPem]); + assert.equal(supplied.status, 0, supplied.stderr); + assert.match(supplied.stderr, /trust verified-against-supplied-key/); + + const stranger = crypto.generateKeyPairSync('ed25519'); + const otherPem = path.join(tmp, 'other.pub.pem'); + fs.writeFileSync(otherPem, stranger.publicKey.export({ format: 'pem', type: 'spki' })); + const mismatch = run('pap-boot.js', ['--bundle', bundle, '--recipient-key', bob, '--verify-key', otherPem]); + assert.equal(mismatch.status, 4, mismatch.stderr); + assert.equal(mismatch.stdout, ''); + assert.match(mismatch.stderr, /not the key this bundle carries|signer mismatch/i); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('R2: pap-boot maps a malformed recipient key to exit 2, not a stack trace', (t) => { + if (gate(t)) return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lotor-pap-sealed-cli-')); + try { + const f = fixture(tmp); + assert.equal(run('pap-export.js', exportArgs(f, tmp)).status, 0); + const junk = path.join(tmp, 'junk.key'); + fs.writeFileSync(junk, 'this is not a key\n'); + const r = run('pap-boot.js', ['--bundle', path.join(tmp, 'c.bin'), '--recipient-key', junk]); + assert.equal(r.status, 2, r.stderr); + assert.equal(r.stdout, ''); + assert.doesNotMatch(r.stderr, /\n\s+at /, 'no stack trace'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('pap-export --help states the chain-key caveat (KNOWN-LIMITS 8, 48) and what sealing leaves public', (t) => { + if (gate(t)) return; + const help = run('pap-export.js', ['--help']); + assert.equal(help.status, 0); + assert.match(help.stdout, /stored unencrypted/); + assert.match(help.stdout, /anyone who can read the key file can sign bundles in your name/); + assert.match(help.stdout, /recipient count, signer, chain head, seq and time are public/); + }); +});