Skip to content

CORE (signing sitting): sealed PAP bundles, encrypt-then-sign - #90

Open
githubscum wants to merge 7 commits into
mainfrom
lotor-lane/pap-sealed
Open

githubscum wants to merge 7 commits into
mainfrom
lotor-lane/pap-sealed

Conversation

@githubscum

@githubscum githubscum commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

WO-CARTRIDGE-SEAL part A (sealed PAP bundles, encrypt-then-sign) plus part D's docs.

Why this is CORE when the WO called it ordinary. src/publish is in CORE_DIRS (src/grant/core-paths.js:73-83, Isaac's classification at the 2026-08-09 sitting, mirrored in the policy's self-mod fragments), and every script in bin/ is core too. The WO's "ordinary PR, src/publish, bin/pap-*" predates neither ruling, and the stricter one wins. So the code is a generated patch, proposals/pap-sealed.patch, queued for the sitting. Nothing under src/ or bin/ changes in this PR.

What lands at the sitting (6 targets, 6 signatures)

src/publish/seal.js (new), src/publish/manifest.js, src/publish/pack.js, bin/pap-export.js, bin/pap-boot.js, bin/pap-keygen.js (new). Steps in proposals/pap-sealed/README.md: regenerate and diff, then one git apply --include=<target> per target so the gate sees each path (KNOWN-LIMITS 82), re-stamp, npm test.

Construction (spec: docs/PAP-SEALED.md)

  • Manifest v2. AES-256-GCM body under a fresh content key. Recipients get X25519-wrapped stanzas (one ephemeral key per bundle, HKDF-SHA256), or a PBKDF2-SHA256 passphrase stanza.
  • Outer Ed25519 signature over the ciphertext manifest. Anyone verifies without a key, and pap-boot with no key exits 5: "verified, sealed to N".
  • Inner Ed25519 signature inside the ciphertext, over the header hash, the spine hash and the memoir hash. It binds the plaintext to its signer, chain head, seq, time, key commitment and every recipient stanza.
  • Public key commitment (not in the WO, added on purpose). AES-GCM is not key-committing. Without it, an author could wrap a different key to each recipient and show each a different plaintext under one signature. For a prediction signed before the outcome, that is the fraud.
  • AES-256-GCM instead of XChaCha20, because it is in both node:crypto and WebCrypto, so the offline reader (part C) needs no AEAD library. Zero nonce, because every key is used once (age's reasoning).
  • Opening returns the content key. pap-boot --content-key opens with it, and the commitment proves it is the right one: commit now, reveal later.
  • v1 untouched. The pack() and unpack() v1 paths are the old code; dispatch is on the version byte.
  • No new dependencies.

Fail-first tests and how each was checked

test/pap-sealed.test.js (17 tests) skips until the patch lands. test/pap-sealed-patch.test.js (3 tests, inside every npm test) builds temp copies and runs the sealed suite with PAP_SEALED_REQUIRE=1:

  • Patched: the sealed suite plus the v1 pap-pack and pap-qr suites, 30/30 pass.
  • Unpatched control: 17/17 red, exit 1. That is the "fails on main" check, and it runs every time.

Each defence was also removed by mutation on a throwaway patched copy (scratch mutate.mjs), checking that the right test goes red:

Mutation Red
M1 outer verify skipped in inspect() WO-1 (any byte flip, no key), CLI exit codes
M2 inner binding not checked WO-2 (re-encrypted or re-addressed under a valid outer sig), shared negative vectors
M3 key commitment not checked key commitment, reveal, shared negative vectors
M4 no clean not-a-recipient error WO-3 (wrong key, clean failure), passphrase, shared vectors, CLI
M5 header not used as AEAD associated data WO-2, key commitment, shared vectors
M6 v1 bytes move (brotli quality 11 to 10) WO-4

WO-4 (pre-change v1 bundle still verifies, and pack() reproduces it byte for byte) passes on main by design: it is a non-regression guard. test-data/pap-vectors/v1-plain.bin was generated by main's pack() before any change, and regenerating it on main reproduced it byte for byte. M6 shows it catches a v1 change.

WO-2 includes a control: the same rebuild with an honestly recomputed inner signature opens. So the rejections come from the binding, not from a broken test harness.

Full npm test counts (each run alone)

Tree tests pass fail skipped
main 337358f 1379 1374 0 5
this branch 1399 1377 0 22 (5 + 17 sealed, staged)
branch + patch applied, git worktree, core.autocrlf=true 1402 1394 0 8 (5 + 3 rehearsal, LANDED)
trial merge with #91 (reader), patch applied, autocrlf 1418 1410 0 8

The trial merge with #91 conflicted only on the KNOWN-LIMITS tail: both PRs take 83, and #91's became 86.

git apply --check proposals/pap-sealed.patch is clean in that autocrlf worktree.

Measured byte budget (pinned in test/pap-sealed.test.js)

Overhead over 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

The 194 B breaks down as ephemeral key 32, stanza 48, commitment 32, inner signature 64, GCM tag 16, counts 2. The WO expected about 60 to 80 B. That estimate left out the inner signature, the ephemeral key and the commitment. The cap stays 2,953 B. pack() refuses over it and names what sealing cost (tested with a spine that fits plain and not sealed).

Shared vectors with #91 (the reader), byte-identical in both PRs:

  • v1-plain.bin, 297 B
  • v2-sealed-two-recipients.bin, 539 B
  • v2-sealed-passphrase.bin, 449 B
  • two negatives with valid outer signatures: v2-neg-readdressed.bin (491 B), refused on the inner binding, and v2-neg-key-commit.bin (509 B), refused on the key commitment

All keys are throwaway test keys, in test-data/pap-vectors/vectors.json.

Part D docs

  • README gains "Signed QR bundles (PAP)". It states that "cannot be forged" means "not without the chain key file", which sits unencrypted on disk (KL 8, 48). Sealed mode is listed under "Not built yet" until the sitting.
  • pap-export --help printed nothing before; it now prints usage plus the same caveat. That text is in the patch, because bin/ is core, and is tested.
  • The wrapped-chain-key proposal is in the brain's NEXT-SIGNING-SITTING.md, not built.

KNOWN-LIMITS

  • 45 amended: what sealed mode leaves public, and the passphrase brute-force caveat.
  • 46 amended: unchanged for sealed bundles.
  • New 83: sealed only while every recipient key stays secret. No forward secrecy, not post-quantum, and a QR is forever.
  • New 84: sealing controls who opens a bundle, not what a recipient does after.
  • New 85: a bundle's time is the signer's claim. Nothing proves "signed before the outcome" without an outside witness, and nothing stops sealing ten predictions and revealing the winner unless every bundle is published first. This one matters for the REA cartridge plan.

Numbers 83 to 85 may collide with another open PR; renumber at merge. Pin re-stamped (--check current).

Guard

bash .github/scripts/pr-guard.sh origin/main HEAD false, using the script from #88's branch: guard: clean. Two things found on the way:

  1. A patch that removes an em dash contains it on its minus lines, so rule 3 fails it. The patch therefore leaves main's three bin/ em-dash lines alone.
  2. The guard's core list (src/gate|policy|chain|store|grant, bin/hook-) is narrower than CORE_DIRS. It would pass a PR that edits src/publish, src/charter, src/limits or any other bin/ script directly. Noted here, not on CI on Linux and Windows, PR intake guard, CONTRIBUTING #88.

Not verified

Amendment (wringer review, 2026-10-26)

A three-oracle wringer pass (kimi and glm answered; sol did not seat) reviewed the crypto in this PR. Both seats called these load-bearing and they are unchanged: encrypt-then-sign order; the inner signature over sha256(header) + sha256(spine) + sha256(memoir) verified with the outer key; the header as AEAD associated data; key commitment checked before decrypt with timingSafeEqual; the age-shape X25519 wrap (one ephemeral per bundle, eph||R salt, all-zero refusal); zero-nonce discipline inside pack(); strict fixed-width decode with an exact trailing length; RFC 8410 prefixes; v1 byte compatibility; KNOWN-LIMITS 45/84/85 existing wording. The numbers earlier in this description are from c49b234; the ones below supersede them. Heads: d9be92d (code, tests, vectors) and cf94e32 (docs, KNOWN-LIMITS).

The five fixes

  1. Trust accounting (v1 and v2). inspect() and unpack() report trust: 'self-consistent' (verify key came from the bundle's own fingerprint) or 'verified-against-supplied-key'. A supplied verifyKey must equal the embedded chainPubkeyFp by raw-key equality, else PAP_SIGNER_MISMATCH before any signature work. The failure message now says what the default path can detect. docs/PAP-SEALED.md "What it claims", the README PAP paragraph and the pap-export help text no longer say the signature proves authorship on its own. pap-boot prints the trust basis; PAP_SIGNER_MISMATCH exits 4. pap-pack.test.js "signed by a different key" now accepts either code.
  2. Decompression cap and error hygiene. MAX_SPINE_RAW_BYTES (4 MiB) is the maxOutputLength of every brotli decode, v1 and v2: PAP_SPINE_TOO_LARGE over it, PAP_DECOMPRESS_FAILED for malformed input, never a raw zlib error. x25519Unwrap returns null only for the low-order refusal; anything else is PAP_BAD_KEY. A content key that is not 32 bytes or 64 hex chars is PAP_BAD_CONTENT_KEY. Key parsers throw coded PAP_BAD_KEY.
  3. KDF floor and disclosures. Sealing below 100,000 PBKDF2 iterations is PAP_KDF_TOO_WEAK; the default stays 600,000. Tests and vectors seal at the floor (100,000). There is no override flag: a switch in production code that lowers the floor is a footgun, and hostile low-count bundles are built by the test-only primitives, which have no floor. The decode side still opens whatever count a bundle carries. KNOWN-LIMITS 45 is amended by addition only (passphrase entropy and the not-memory-hard offline model; the recipient-membership oracle). The budget section names the swapped operand: v2's u16 ciphertextLen replaces v1's u16 spine-length tag, which is why the line items sum to 194.
  4. Outer domain string (format-freezing, v2 only). The outer signature covers "lotor/pap/v2/outer" 0x00 || manifest. v1 stays byte for byte: pack() still reproduces the committed v1-plain.bin exactly.
  5. Zero-nonce primitives un-exported (format-freezing). seal.js exports a single-use createSealer({ to, passphrase, iterations }): the content key, ephemeral key and every KEK live in its closure, and encryptBody() throws PAP_SEALER_REUSED on a second call. aeadSeal, bodyKey, generateContentKey and x25519Wrap are module-private, and pack.js re-exports none of them. Tests and make-vectors.mjs get low-level sealing from test-data/pap-vectors/seal-primitives.mjs, an independent node:crypto reimplementation outside test/, labelled test-only. A test pins the exact export surface of seal.js and pack.js.

Fail-first evidence

Red means the new test fails on the pre-fix patch (the c49b234 patch applied to a throwaway copy, with the new tests in place). Green is after the fix. Mutations were run on a throwaway copy of the fixed patch (scratch mutate2.mjs).

Fix Test Red before (pre-fix patched code) Green after Mutation that turns it red
1 R1: trust is a distinct field; a supplied key must equal the embedded fingerprint info.trust is undefined, expected 'self-consistent' yes supplied key not required to equal fingerprint; trust label always self-consistent
1 R1: "signed by someone else but carrying my fingerprint" message was the "different key" text yes old message restored
1 CLI R1: pap-boot trust line and exit 4 on mismatch no trust line in output yes same two
2 R2: a brotli bomb is refused (v1 and v2) v1 bomb: NO_THROW (8 MiB expanded) yes maxOutputLength removed (v2); raw call restored (v1)
2 R2: x25519Unwrap null only for low-order short ephemeral key: NO_THROW (swallowed) yes catch-all restored
2 R2: bad content key is PAP_BAD_CONTENT_KEY got PAP_KEY_COMMIT_MISMATCH yes parse removed
2 R2: malformed recipient key is PAP_BAD_KEY; CLI R2 exit 2 got ERR_OSSL_UNSUPPORTED; stack trace yes not mutated
3 R3: below the PBKDF2 floor is refused iterations 1: NO_THROW yes floor check removed
4 R4: outer signature covers the domain string; raw-manifest signature refused crypto.verify over the domain message was false yes verify side bare; sign side bare; domain added to v1 (v1 tests red)
4 WO-2, key commitment, shared negative vectors (rebuilt with the domain) rejected on the outer signature / missing vector yes same
5 R5: nothing importable encrypts under a caller-supplied key seal.js must not export aeadSeal yes aeadSeal re-exported
5 R5: the single-use sealer createSealer is not a function yes reuse guard removed

The original six mutations (M1 to M6) were re-run against the new code and each still turns its tests red. One more guard from the review: a v1 decoder stops on the version byte of a v2 manifest (tested; kimi noted this was asserted, not shown).

Counts

Tree tests pass fail skipped
main 337358f 1379 1374 0 5
this branch cf94e32 1416 1382 0 34 (5 + 29 sealed, staged)
branch + patch applied, git worktree, core.autocrlf=true 1419 1411 0 8 (5 + 3 rehearsal, LANDED)

The sealed suite is now 29 tests (was 17), plus 5 docs tests in test/pap-sealed-docs.test.js that run on main too. Patched copy: the sealed, v1 pap-pack and pap-qr suites pass 42/42; the unpatched control still fails every sealed test. git apply --check proposals/pap-sealed.patch is clean in the autocrlf worktree. The trial merge with #91 was not re-run after this amendment.

Budget (unchanged, pinned)

1 key 194 B, each extra key +48 B, passphrase only 182 B, 1 key + passphrase 262 B. The patch is now 57,924 bytes (was 44,176).

Vectors

v1-plain.bin is byte-identical (sha256 6f96c791c87c0955310211b26c5a5883603fe33fbc61e1a3b68fa14081122f78). The v2 vectors are regenerated (outer domain string; the passphrase vector at the 100,000 floor) and a third negative, v2-neg-outer-no-domain, is added: an outer signature over the bare manifest, which a correct reader refuses at the outer signature. test-data/pap-vectors tree hash: f24a0240b3cc9ac7ef259ad240d34c62ec50ffc8.

Deliberately not changed, and caveats

  • Membership oracle, corrected. The review text says anyone holding a candidate public key or passphrase can test membership. For X25519 that is not what the construction allows: the unwrap needs the secret half, and stanzas carry no recipient identifier. A candidate passphrase or a candidate private key (leaked or seized) can test it. KNOWN-LIMITS 45 and the doc say that, not the review's wording.
  • The key-commitment rationale is reframed in the doc and in seal.js as defence in depth plus the verifiable reveal (the review's point that the named fraud is expensive against random keys). The mechanism is unchanged.
  • pack() v1 does not refuse a raw spine over 4 MiB (that would change v1 behavior beyond the error code and the new field). pack() v2 does, since a sealed bundle its own reader refuses would be a bug. A v1 writer can in theory emit a bundle the new reader refuses; a spine that large cannot fit a QR anyway except as a bomb.
  • The decode side still accepts any PBKDF2 count up to 10,000,000 (now a coded PAP_KDF_BAD_PARAMS above it). A hostile bundle can still make an opener spend that many rounds. Not changed; noted.
  • Passphrase mode remains PBKDF2 (WebCrypto constraint). The floor is a floor, not a recommendation.
  • Not verified: still no live-home run of the CLIs, and no browser other than a Chromium pane (see Offline PAP reader: scan, verify, open, no network (WO-CARTRIDGE-SEAL C) #91).

githubscum and others added 5 commits October 26, 2026 18:02
WO-CARTRIDGE-SEAL part A, plus D's chain-key caveat in the README and
pap-export --help. src/publish and bin/ are core (core-paths CORE_DIRS,
classified at the 2026-08-09 sitting), so the code is a generated patch,
proposals/pap-sealed.patch, with a generator, a rehearsal test that applies
it to a temp copy, and the sealed suite that skips until it lands.

Sealed mode: AES-256-GCM body under a fresh content key, X25519 or PBKDF2
wrapped per recipient, a public key commitment, an inner Ed25519 signature
binding the plaintext to its header, and the outer Ed25519 signature over
the ciphertext manifest. v1 bundles are unchanged byte for byte.

KNOWN-LIMITS: 45 and 46 amended; new 83, 84, 85 (renumber at merge if an
open PR also appends).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m main

The guard reads the whole-file proposals as added lines. The regenerated
patch now also removes the three em dashes from bin/ when it lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The proposed copies hold dash-free lines; the generator restores the
originals, so the patch neither adds nor removes an em dash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both carry a valid outer signature. A correct reader refuses the first on
the inner binding and the second on the key commitment (r1 still opens it).
The offline reader checks the same bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
githubscum and others added 2 commits October 26, 2026 19:19
1. Trust accounting. inspect() and unpack(), v1 and v2, report
   trust: 'self-consistent' or 'verified-against-supplied-key'. A supplied
   verifyKey must equal the embedded chainPubkeyFp (raw-key equality) or the
   call throws PAP_SIGNER_MISMATCH before any signature check. The failure
   message now says only what the default path can detect. pap-boot prints
   the trust basis and maps the mismatch to exit 4.
2. Decompression cap and error hygiene. MAX_SPINE_RAW_BYTES (4 MiB) caps every
   brotli decode, v1 and v2; over it is PAP_SPINE_TOO_LARGE, malformed is
   PAP_DECOMPRESS_FAILED. x25519Unwrap returns null only for the low-order
   refusal; anything else is PAP_BAD_KEY. A malformed content key is
   PAP_BAD_CONTENT_KEY, not a commitment mismatch.
3. KDF floor. Sealing below 100000 PBKDF2 iterations throws PAP_KDF_TOO_WEAK.
   Default stays 600000. Tests and vectors seal at the floor; there is no
   override. Hostile low-count bundles come from the test-only primitives.
4. Outer domain string, v2 only. The outer signature covers
   "lotor/pap/v2/outer" 0x00 || manifest. v1 is byte for byte unchanged and
   the committed v1 vector is still reproduced exactly.
5. The zero-nonce primitives are no longer exported. seal.js exposes a
   single-use createSealer(); pack.js re-exports nothing that encrypts.
   Tests and make-vectors get low-level sealing from
   test-data/pap-vectors/seal-primitives.mjs, an independent node:crypto
   reimplementation outside test/. A test pins the exact export surfaces.

Vectors regenerated (domain string, floor-count passphrase vector) plus a
third negative, v2-neg-outer-no-domain. v1-plain.bin is byte-identical.
Byte budget unchanged: 194 / +48 / 182 / 262.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
PAP-SEALED.md: "What it claims" now says verification is self-consistency and
authorship needs a fingerprint you already trust; layout and reader order carry
the outer domain string, the trust basis, the 4 MiB cap and the KDF floor; the
key-commitment rationale is reframed as defence in depth plus the verifiable
reveal; the budget section names the swapped u16 operand (ciphertextLen in
place of v1's spine-length tag). README's PAP paragraph matches.

KNOWN-LIMITS 45 is amended by addition only: passphrase entropy and the
not-memory-hard offline model, the recipient-membership oracle (testable by a
holder of the secret, not by a public key alone), and the precise reading of
"proves who authored". Entries 84 and 85 are untouched.

test/pap-sealed-docs.test.js pins those claims and runs on main too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
githubscum added a commit that referenced this pull request Oct 27, 2026
…ent key)

- v2 outer signatures are verified over "lotor/pap/v2/outer" 0x00 || manifest,
  matching #90 exactly. v1 still verifies over the bare manifest.
- verify() reports trust: 'self-consistent' on a pass (the page has no key of
  its own to supply). The status line no longer claims a signer: it says the
  signature matches the fingerprint the bundle carries and to compare that
  fingerprint with one you trust. The page shows a trust-basis row.
  "Verified" still comes only from reader.verify() and reader.open().
- A bundle over the 2,953 B QR cap is refused (PAP_BUNDLE_TOO_LARGE) before any
  decoding; the vendored decoder has no output cap. A decoded spine over 4 MiB
  is PAP_SPINE_TOO_LARGE and a decoder failure is PAP_DECOMPRESS_FAILED, the
  same codes pack.js uses.
- A content key that is not 64 hex chars is PAP_BAD_CONTENT_KEY.
- Shared vectors copied byte for byte from #90 (the outer domain string, the
  passphrase vector at the PBKDF2 floor, and a third negative whose outer
  signature lacks the domain string). index.html rebuilt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant