Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ them forever).
fixture added on `main` afterwards is not blamed on your branch). Add fixtures with
`pnpm envelope:fixtures` after a `pnpm build`; it skips existing files, and
`--force` exists only for a deliberate rebuild before the corpus is merged.
- **One fixture is not a tier, and cannot come from HEAD.** `legacy-armored-body.json`
records an in-body ASCII armor block. Nothing has *emitted* one since 1.0.1 —
`src/email/extract.ts` keeps `extractArmoredCiphertext` readable under
COMPATIBILITY.md's stored-artifact guarantee and its header forbids adding an
emitter — so the generator builds it by calling a real old sender:
`pg-js-legacy-armor` in `packages/pg-js` devDependencies is
`npm:@e4a/pg-js@0.10.0`, the last published version that armored bodies. That
boundary is measured from the published tarballs, not read off a changelog; note
it disagrees with extract.ts's "pg-js >= 1.1" wording, since 1.0.1 already emits
nothing. 0.10.0 predates the tier split, so the fixture carries `tier: null`, and
the corpus-shape assertion requires every untiered fixture to carry an
`expect.armoredBase64Sha256` — otherwise `tier: null` becomes a way to opt out of
the tier-coverage check. Added in #235, after mutation testing showed that
deleting `extractArmoredCiphertext` outright left the archival suite green.
- **The forward test derives every expectation from `result.tier`, never from
`result.attachment` or `result.uploadUuid`.** This looks like it could be
simplified and cannot: branching on the output under test means a HEAD that
Expand Down
1 change: 1 addition & 0 deletions packages/pg-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
},
"devDependencies": {
"@types/node": "^22.20.1",
"pg-js-legacy-armor": "npm:@e4a/pg-js@0.10.0",
"pg-js-reader-v1": "npm:@e4a/pg-js@1.11.0",
"pg-js-reader-v2": "npm:@e4a/pg-js@2.3.4",
"tsdown": "^0.22.0",
Expand Down
111 changes: 106 additions & 5 deletions packages/pg-js/scripts/generate-envelope-fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ const force = process.argv.includes('--force');
// the corpus is append-only, whatever goes in here is permanent for that file.
// The dirty marker matters: without it a fixture generated from uncommitted work
// would name a commit whose tree never produced these bytes.
function provenance() {
const { version } = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
function provenance(sdkVersion) {
const git = (args) => execFileSync('git', args, { cwd: pkgDir, encoding: 'utf8' }).trim();
let commit = 'unknown commit';
let dirty = '';
Expand All @@ -41,10 +40,14 @@ function provenance() {
// Generating outside a checkout is legitimate; recording a commit that is a
// guess is not.
}
return `@e4a/pg-js ${version} createEnvelope, generated at postguard-js ${commit}${dirty}`;
return `@e4a/pg-js ${sdkVersion} createEnvelope, generated at postguard-js ${commit}${dirty}`;
}

const producedBy = provenance();
function versionOf(pkgJsonPath) {
return JSON.parse(readFileSync(pkgJsonPath, 'utf8')).version;
}

const producedBy = provenance(versionOf(join(pkgDir, 'package.json')));

// From dist/, not src/: the TS sources use `.js` specifiers that bare node does
// not resolve, and the built package is what consumers actually receive — so the
Expand Down Expand Up @@ -78,6 +81,14 @@ function payload(size) {
return out;
}

// The markers as the legacy sender wrote them. Kept local rather than taken from
// src/email/extract.ts — which does not export them anyway — because the check
// below asks whether the legacy body really carries these exact bytes. Sharing
// HEAD's constants would make it agree with HEAD by construction, and the point
// of the fixture is to notice when HEAD drifts.
const ARMOR_BEGIN = '-----BEGIN POSTGUARD MESSAGE-----';
const ARMOR_END = '-----END POSTGUARD MESSAGE-----';

function toBase64(bytes) {
return Buffer.from(bytes).toString('base64');
}
Expand Down Expand Up @@ -123,6 +134,86 @@ async function envelopeFixture({ name, description, bytes, uploadToCryptify = tr
ciphertextSha256: attachment ? sha256(bytes) : null,
ciphertextLength: attachment ? bytes.length : null,
uploadUuid: result.uploadUuid,
// Null, not absent: HEAD emits no body armor, and a reader handed one of
// these envelopes must recover nothing from the body rather than
// something. `legacyArmoredFixture` is the only producer that fills it.
armoredBase64Sha256: null,
},
};
}

// The one fixture HEAD cannot produce.
//
// COMPATIBILITY.md's archival guarantee covers in-body ASCII armor, and
// src/email/extract.ts keeps `extractArmoredCiphertext` alive for it — but
// nothing has *emitted* armor since 1.0.1, and teaching createEnvelope to emit
// it again just to have something to record is the exact change that file's
// header forbids. So this fixture comes from the last published sender that
// really did emit it, aliased in devDependencies as `pg-js-legacy-armor`
// (@e4a/pg-js 0.10.0). Those are bytes that reached real mailboxes, which is the
// only kind of bytes this corpus is a promise about.
//
// 0.10.0 predates the tier split, so its result has no `tier` and no
// `uploadUuid`. The fixture records `tier: null` rather than guessing one; see
// the corpus-shape assertion in tests/envelope-archival.test.ts.
async function legacyArmoredFixture({ name, description, bytes }) {
const legacyDir = join(pkgDir, 'node_modules', 'pg-js-legacy-armor');
if (!existsSync(legacyDir)) {
console.error('pg-js-legacy-armor is not installed. Run `pnpm install` at the repo root.');
process.exit(1);
}
const { createEnvelope: createLegacyEnvelope } = await import('pg-js-legacy-armor');

const result = await createLegacyEnvelope({
sealed: makeSealed(bytes),
from: 'sender@example.com',
unencryptedMessage: 'A fixture message.',
websiteUrl: 'https://postguard.eu',
});

// Derived from `bytes`, NOT by running HEAD's extractArmoredCiphertext over
// the body: an expectation computed with the reader under test is satisfied by
// definition, including when the reader is broken. The check below is a
// separate, deliberately dumb whitespace strip — it confirms the legacy sender
// really armored these bytes, and it fails loudly if 0.10.0's block ever holds
// something other than plain wrapped base64 (HEAD's reader additionally strips
// HTML tags, for clients that re-wrap the block in transit).
const armoredBase64 = toBase64(bytes);
const begin = result.htmlBody.indexOf(ARMOR_BEGIN);
const end = result.htmlBody.indexOf(ARMOR_END, begin);
if (begin < 0 || end < 0) {
throw new Error(`pg-js ${versionOf(join(legacyDir, 'package.json'))} emitted no armor block`);
}
const inBlock = result.htmlBody.slice(begin + ARMOR_BEGIN.length, end).replace(/\s+/g, '');
if (inBlock !== armoredBase64) {
throw new Error("the legacy armor block does not hold this fixture's ciphertext");
}

const attachment = {
name: result.attachment.name,
contentType: result.attachment.type,
dataBase64: toBase64(new Uint8Array(await result.attachment.arrayBuffer())),
};

return {
name,
description,
producedBy: provenance(versionOf(join(legacyDir, 'package.json'))),
tier: null,
subject: result.subject,
htmlBody: result.htmlBody,
plainTextBody: result.plainTextBody,
attachment,
uploadUuid: null,
expect: {
ciphertextSha256: sha256(bytes),
ciphertextLength: bytes.length,
uploadUuid: null,
// A digest of the base64 *string* a reader must hand back, for the same
// reason ciphertextSha256 is a digest: the attachment above already holds
// these bytes. It pins the exact string form — standard base64, whitespace
// stripped — which is what an add-in feeds to pg.open().
armoredBase64Sha256: sha256(armoredBase64),
},
};
}
Expand Down Expand Up @@ -159,6 +250,16 @@ const fixtures = [
'which is the asymmetry that broke the website uuid path (#39).',
bytes: payload(10 * 1024 * 1024 + 1),
}),
await legacyArmoredFixture({
name: 'legacy-armored-body',
description:
'Archived envelope from @e4a/pg-js 0.10.0, the last published sender that emitted an ' +
'in-body ASCII armor block (1.0.1 no longer does; the drop is what broke Thunderbird ' +
'detection in postguard-tb-addon#85). Predates the tier split, so it has no tier. ' +
'Bodies like this are still sitting in real mailboxes, and COMPATIBILITY.md says read ' +
'support for them never expires — so extractArmoredCiphertext has to keep working.',
bytes: payload(512),
}),
];

mkdirSync(outDir, { recursive: true });
Expand All @@ -172,7 +273,7 @@ for (const fixture of fixtures) {
}
writeFileSync(path, `${JSON.stringify(fixture, null, 2)}\n`);
written++;
console.log(` wrote ${fixture.name}.json (${fixture.tier})`);
console.log(` wrote ${fixture.name}.json (${fixture.tier ?? 'no tier'})`);
}
console.log(`${written} written, ${skipped} left alone (already present).`);
if (skipped > 0 && !force) {
Expand Down
72 changes: 68 additions & 4 deletions packages/pg-js/tests/envelope-archival.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,45 @@
// This class has shipped twice: pg-js >=1.1 silently stopped emitting body armor
// and broke Thunderbird detection (postguard-tb-addon#85), and the data.bin zip
// asymmetry broke the website's uuid decrypt path (#39).
//
// The armor half of that had no fixture until #235, so deleting
// extractArmoredCiphertext left this suite green. `legacy-armored-body.json`
// records what @e4a/pg-js 0.10.0 actually put on the wire, and every fixture now
// asserts what a reader must recover from its body — nothing, or exactly that
// block.

import { createHash } from 'node:crypto';
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { extractCiphertext, extractUploadUuid } from '../src/email/extract.js';
import {
extractArmoredCiphertext,
extractCiphertext,
extractUploadUuid,
} from '../src/email/extract.js';

const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'envelopes');

interface Fixture {
name: string;
description: string;
tier: string;
/** Null for a fixture that predates the tier split — currently the armored
* body from 0.10.0. See the corpus-shape assertion below for why that is not
* a hole anyone can widen. */
tier: string | null;
htmlBody: string;
attachment: { name: string; contentType: string; dataBase64: string } | null;
uploadUuid: string | null;
expect: {
ciphertextSha256: string | null;
ciphertextLength: number | null;
uploadUuid: string | null;
/** Digest of the base64 string `extractArmoredCiphertext` must recover from
* `htmlBody`. Null or absent means this envelope carries no armor block and
* a reader must recover nothing — the four fixtures added in #131 predate
* the field and are that case. */
armoredBase64Sha256?: string | null;
};
}

Expand All @@ -47,10 +65,28 @@ describe('envelope archival compatibility', () => {
// Without this the suite would report success while asserting nothing, which
// is the failure mode the whole gate exists to prevent. An empty directory,
// a renamed path or a bad glob all land here.
it('the corpus is non-empty and covers every tier', () => {
it('the corpus is non-empty, covers every tier, and holds an armored body', () => {
expect(fixtures.length).toBeGreaterThan(0);
const tiers = new Set(fixtures.map((f) => f.tier));

// Tier-bearing fixtures still have to cover all three exactly. The armored
// fixture is excluded rather than added to the expected list: adding it there
// would mean a missing tier could be papered over by whatever else happens
// to be in the directory.
const tiers = new Set(fixtures.filter((f) => f.tier !== null).map((f) => f.tier));
expect([...tiers].sort()).toEqual(['tier1', 'tier2', 'tier3']);

// And `tier: null` is not a free pass. The only reason a fixture may lack a
// tier is that it records a format from before tiers existed, which is the
// armor case — so an untiered fixture must carry an armor expectation.
for (const untiered of fixtures.filter((f) => f.tier === null)) {
expect(typeof untiered.expect.armoredBase64Sha256).toBe('string');
}

// The corpus must actually hold one. Without this the armor case below takes
// the "no armor here" branch for every fixture and the suite passes having
// asserted nothing about extractArmoredCiphertext — the archival reader with
// no live caller, and so the easiest one to delete (postguard-js#235).
expect(fixtures.some((f) => typeof f.expect.armoredBase64Sha256 === 'string')).toBe(true);
});

describe.each(fixtures.map((f) => [f.name, f] as const))('%s', (_name, fixture) => {
Expand Down Expand Up @@ -87,6 +123,34 @@ describe('envelope archival compatibility', () => {
);
});

it('HEAD recovers the in-body armor this envelope carries', () => {
const recovered = extractArmoredCiphertext(fixture.htmlBody);
const expected = fixture.expect.armoredBase64Sha256 ?? null;

if (expected === null) {
// Every envelope HEAD itself can produce. A reader must come back empty
// rather than mistaking part of the body for a payload.
expect(recovered).toBeNull();
return;
}

expect(recovered).not.toBeNull();
expect(createHash('sha256').update(recovered!).digest('hex')).toBe(expected);

if (fixture.expect.ciphertextSha256 !== null) {
// The block has to decode back to the same ciphertext the attachment
// carries, so a reader that finds the block but mangles it — url-safe
// substitution, dropped padding, newlines left in — fails here instead
// of downstream in pg.open(). Guarded because an armor-only archived
// message, with no attachment at all, is a legitimate future fixture.
const decoded = new Uint8Array(Buffer.from(recovered!, 'base64'));
expect(decoded.length).toBe(fixture.expect.ciphertextLength);
expect(createHash('sha256').update(decoded).digest('hex')).toBe(
fixture.expect.ciphertextSha256
);
}
});

it('HEAD recovers the Cryptify uuid this envelope advertises', () => {
expect(extractUploadUuid(fixture.htmlBody)).toBe(fixture.expect.uploadUuid);
});
Expand Down
Loading
Loading