Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# The offline reader page is compared by SHA-256 against a fresh build, and
# its pinned vendor files by hash: keep their bytes identical in every checkout.
web/reader/index.html text eol=lf
web/reader/vendor/*.js text eol=lf

# Staged patches must keep LF line endings in every checkout. With
# core.autocrlf=true (the Windows default) a .patch is otherwise written as
# CRLF, its blank context lines become "\r", and `git apply` rejects the file
Expand Down
30 changes: 28 additions & 2 deletions KNOWN-LIMITS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<!-- known-limits:pin v1
This file describes commit 1c3e9f2480ae8929a6dc84b644e0ed12a0e99bd3
stamped 2026-10-26
stamped 2026-10-27
subject: Signing sitting 2026-10-26: land agent-id, l77 and seq-continuity core under signature
body-sha256: 6813c8dbe4980f55c2f1e7606ef0eaadbc817cd85d6fbfbabf372f015d5941a5
body-sha256: 18c6ef213d1e63b40d753be04c608c151bc884c6eae40b4c1e156a61ded1b4e2
Re-stamp after updating this log: npm run limits-pin -- --stamp
Divergent checkout? See: npm run limits-pin -- --check
known-limits:pin end -->
Expand Down Expand Up @@ -3299,3 +3299,29 @@ 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. The offline reader is only as trustworthy as the copy of it you run

`web/reader/index.html` verifies PAP bundles in the browser, and "anyone can
verify" holds only for someone running an honest copy of that page. A page
served by whoever wants a bundle believed can print "Verified" for anything; the
signature math being right in this repository does nothing for a visitor to a
different page. The defence is the one any offline verifier has: save the file,
compare its SHA-256 with the one built from this repository
(`node web/reader/build.mjs` prints it), run it offline, or verify with
`pap-boot` instead. Also unproven: decoding a version-40 QR from a phone camera.
The byte path is tested from a rendered image (`test/pap-reader.test.js`), not
from a real camera, and a 177-module code is hard to capture; loading a photo
or pasting the bytes are the dependable routes. The camera needs the page served
over https or from localhost, and opening a sealed bundle needs WebCrypto X25519
(no fallback is shipped for it). The page reports a pass as "self-consistent"
and never as authorship: the fingerprint it checks against is the one inside the
bundle, so it names a signer only if you compare it with one you already trust.
The vendored brotli decoder has no output limit. The page refuses any input
over the 2,953 B QR cap before decoding and refuses a decoded spine over 4 MiB
after it, but a bundle at or under the cap can still make the decoder allocate
far more than that before the second check runs, so a hostile bundle can stall
or exhaust the tab. Residual risk, not closed: open unknown bundles in a
throwaway tab, or use `pap-boot`, which caps the output while it decodes.
(Number may collide with an open PR that also appends a limit; renumber at
merge.)
186 changes: 186 additions & 0 deletions test-data/pap-vectors/make-vectors.mjs
Original file line number Diff line number Diff line change
@@ -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(', '));
103 changes: 103 additions & 0 deletions test-data/pap-vectors/seal-primitives.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
Binary file added test-data/pap-vectors/v1-plain.bin
Binary file not shown.
Binary file added test-data/pap-vectors/v2-neg-key-commit.bin
Binary file not shown.
Binary file added test-data/pap-vectors/v2-neg-outer-no-domain.bin
Binary file not shown.
Binary file added test-data/pap-vectors/v2-neg-readdressed.bin
Binary file not shown.
Binary file added test-data/pap-vectors/v2-sealed-passphrase.bin
Binary file not shown.
Binary file added test-data/pap-vectors/v2-sealed-two-recipients.bin
Binary file not shown.
Loading