Repository navigation
CORE (signing sitting): sealed PAP bundles, encrypt-then-sign - #90
Open
githubscum wants to merge 7 commits into
Open
githubscum wants to merge 7 commits into
githubscum wants to merge 7 commits into
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/publishis inCORE_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 inbin/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 undersrc/orbin/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 inproposals/pap-sealed/README.md: regenerate and diff, then onegit apply --include=<target>per target so the gate sees each path (KNOWN-LIMITS 82), re-stamp,npm test.Construction (spec:
docs/PAP-SEALED.md)pap-bootwith no key exits 5: "verified, sealed to N".pap-boot --content-keyopens with it, and the commitment proves it is the right one: commit now, reveal later.pack()andunpack()v1 paths are the old code; dispatch is on the version byte.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 everynpm test) builds temp copies and runs the sealed suite withPAP_SEALED_REQUIRE=1:pap-packandpap-qrsuites, 30/30 pass.Each defence was also removed by mutation on a throwaway patched copy (scratch
mutate.mjs), checking that the right test goes red:inspect()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.binwas generated by main'spack()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 testcounts (each run alone)337358fcore.autocrlf=trueThe 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.patchis 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:
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 Bv2-sealed-two-recipients.bin, 539 Bv2-sealed-passphrase.bin, 449 Bv2-neg-readdressed.bin(491 B), refused on the inner binding, andv2-neg-key-commit.bin(509 B), refused on the key commitmentAll keys are throwaway test keys, in
test-data/pap-vectors/vectors.json.Part D docs
pap-export --helpprinted nothing before; it now prints usage plus the same caveat. That text is in the patch, becausebin/is core, and is tested.NEXT-SIGNING-SITTING.md, not built.KNOWN-LIMITS
Numbers 83 to 85 may collide with another open PR; renumber at merge. Pin re-stamped (
--checkcurrent).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:bin/em-dash lines alone.src/gate|policy|chain|store|grant,bin/hook-) is narrower thanCORE_DIRS. It would pass a PR that editssrc/publish,src/charter,src/limitsor any otherbin/script directly. Noted here, not on CI on Linux and Windows, PR intake guard, CONTRIBUTING #88.Not verified
pap-keygen(no key directory, no rotation).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||Rsalt, all-zero refusal); zero-nonce discipline insidepack(); 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 fromc49b234; the ones below supersede them. Heads:d9be92d(code, tests, vectors) andcf94e32(docs, KNOWN-LIMITS).The five fixes
inspect()andunpack()reporttrust: 'self-consistent'(verify key came from the bundle's own fingerprint) or'verified-against-supplied-key'. A suppliedverifyKeymust equal the embeddedchainPubkeyFpby raw-key equality, elsePAP_SIGNER_MISMATCHbefore 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 thepap-exporthelp text no longer say the signature proves authorship on its own.pap-bootprints the trust basis;PAP_SIGNER_MISMATCHexits 4.pap-pack.test.js"signed by a different key" now accepts either code.MAX_SPINE_RAW_BYTES(4 MiB) is themaxOutputLengthof every brotli decode, v1 and v2:PAP_SPINE_TOO_LARGEover it,PAP_DECOMPRESS_FAILEDfor malformed input, never a raw zlib error.x25519Unwrapreturns null only for the low-order refusal; anything else isPAP_BAD_KEY. A content key that is not 32 bytes or 64 hex chars isPAP_BAD_CONTENT_KEY. Key parsers throw codedPAP_BAD_KEY.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 u16ciphertextLenreplaces v1's u16 spine-length tag, which is why the line items sum to 194."lotor/pap/v2/outer" 0x00 || manifest. v1 stays byte for byte:pack()still reproduces the committedv1-plain.binexactly.seal.jsexports a single-usecreateSealer({ to, passphrase, iterations }): the content key, ephemeral key and every KEK live in its closure, andencryptBody()throwsPAP_SEALER_REUSEDon a second call.aeadSeal,bodyKey,generateContentKeyandx25519Wrapare module-private, andpack.jsre-exports none of them. Tests andmake-vectors.mjsget low-level sealing fromtest-data/pap-vectors/seal-primitives.mjs, an independent node:crypto reimplementation outsidetest/, labelled test-only. A test pins the exact export surface ofseal.jsandpack.js.Fail-first evidence
Red means the new test fails on the pre-fix patch (the
c49b234patch 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 (scratchmutate2.mjs).info.trustisundefined, expected'self-consistent'trustline in outputNO_THROW(8 MiB expanded)maxOutputLengthremoved (v2); raw call restored (v1)NO_THROW(swallowed)PAP_KEY_COMMIT_MISMATCHERR_OSSL_UNSUPPORTED; stack traceNO_THROWcrypto.verifyover the domain message was falseseal.js must not export aeadSealaeadSealre-exportedcreateSealer is not a functionThe 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
337358fcf94e32core.autocrlf=trueThe sealed suite is now 29 tests (was 17), plus 5 docs tests in
test/pap-sealed-docs.test.jsthat run on main too. Patched copy: the sealed, v1pap-packandpap-qrsuites pass 42/42; the unpatched control still fails every sealed test.git apply --check proposals/pap-sealed.patchis 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.binis byte-identical (sha2566f96c791c87c0955310211b26c5a5883603fe33fbc61e1a3b68fa14081122f78). 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-vectorstree hash:f24a0240b3cc9ac7ef259ad240d34c62ec50ffc8.Deliberately not changed, and caveats
seal.jsas 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.PAP_KDF_BAD_PARAMSabove it). A hostile bundle can still make an opener spend that many rounds. Not changed; noted.