diff --git a/README.md b/README.md index bddd034..7cc936d 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,16 @@ catalog bytes with: node scripts/validate-catalog.mjs --strict-envelope ``` +Local `check:signature` uses `--candidate`: it verifies the historical envelope +signature and permits changed source bytes only at a greater Store sequence. +Exact already-signed bytes also pass. Production publication and the signing +dry-run keep strict byte-equality validation. + +Store and Package Index have independent catalog sequences. Store catalog 14 +reconciles discovery with the already-published Package Index catalog 13; +it does not rewrite that release or its BOM. The reconciliation fixture records +both sequences and the downloaded Package Index catalog/BOM hashes. + ## Key rotation Rotate the signing key as a coordinated change: add the new public key and diff --git a/catalog.json b/catalog.json index 2457338..e2e3b5b 100644 --- a/catalog.json +++ b/catalog.json @@ -1,8 +1,8 @@ { "schema_version": 1, - "sequence": 13, - "issued_at": "2026-08-30T00:00:00Z", - "expires_at": "2027-08-29T00:00:00Z", + "sequence": 14, + "issued_at": "2026-09-04T23:17:36Z", + "expires_at": "2027-09-04T23:17:36Z", "listings": [ { "id": "com.kosmos.shell", @@ -44,7 +44,7 @@ "categories": ["productivity", "accessibility"], "availability": { "platforms": ["windows"] }, "data_compatibility": [], - "distribution": { "package_id": "com.kosmos.dictation", "version": "0.2.2" }, + "distribution": { "package_id": "com.kosmos.dictation", "version": "0.2.3" }, "connects_to": null, "icon_url": "https://github.com/makekosmos/store/releases/download/store-assets-1/dictation-0.2.0.png", "screenshots": [] diff --git a/package.json b/package.json index 654158b..5728fd4 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "prepare": "node scripts/install-hooks.mjs", "test": "node --test scripts/*.test.mjs", - "check:signature": "node scripts/validate-catalog.mjs --strict-envelope && bun run test && node scripts/dry-run.mjs && node --check scripts/sign-catalog.mjs && node --check scripts/check-release-sequence.mjs", + "check:signature": "node scripts/validate-catalog.mjs --candidate && bun run test && node scripts/dry-run.mjs && node --check scripts/sign-catalog.mjs && node --check scripts/check-release-sequence.mjs", "check": "bun run check:signature" } } diff --git a/scripts/fixtures/package-index-release.v1.json b/scripts/fixtures/package-index-release.v1.json index 9462275..4c89853 100644 --- a/scripts/fixtures/package-index-release.v1.json +++ b/scripts/fixtures/package-index-release.v1.json @@ -1,7 +1,11 @@ { - "store_sequence": 13, + "store_sequence": 14, + "package_index_sequence": 13, + "package_index_catalog_sha256": "3f2675135a976f874d612344b9c7b4d75922a128dc614426bad41683b530bafd", + "package_index_release_bom_sha256": "772d5c673d0e2bb9b98a266a04b508ea9d772735d4aa8034ae55ade07732773b", "packages": { "com.kosmos.arcadia": "0.1.8", + "com.kosmos.dictation": "0.2.3", "com.kosmos.agenda": "0.2.4", "com.kosmos.memoria": "0.6.3" } diff --git a/scripts/validate-catalog.mjs b/scripts/validate-catalog.mjs index 841a20e..f5448ec 100644 --- a/scripts/validate-catalog.mjs +++ b/scripts/validate-catalog.mjs @@ -150,6 +150,12 @@ export function validateCatalog(catalog, envelope, options = {}) { assert(envelope, "committed envelope is required"); const envelopeResult = validateEnvelope(envelope, options); if (options.strictEnvelope) assert(envelopeResult.payload.sequence === result.sequence, "catalog and envelope sequences differ"); + if (options.candidate) { + assert(options.catalogBytes, "candidate validation requires catalog bytes"); + const matches = Buffer.compare(envelopeResult.bytes, options.catalogBytes) === 0; + assert(matches || result.sequence > envelopeResult.payload.sequence, + "an unsigned catalog candidate must advance the signed sequence"); + } return result; } @@ -159,7 +165,11 @@ async function main() { const catalogBytes = await readFile(catalogPath); const catalog = JSON.parse(catalogBytes); const envelope = JSON.parse(await readFile(envelopePath, "utf8")); - const result = validateCatalog(catalog, envelope, { strictEnvelope: process.argv.includes("--strict-envelope"), catalogBytes }); + const result = validateCatalog(catalog, envelope, { + strictEnvelope: process.argv.includes("--strict-envelope"), + candidate: process.argv.includes("--candidate"), + catalogBytes, + }); console.log(`Validated catalog sequence ${result.sequence} with ${result.listings} listings.`); } diff --git a/scripts/validate-catalog.test.mjs b/scripts/validate-catalog.test.mjs index 5c5bfa4..5568469 100644 --- a/scripts/validate-catalog.test.mjs +++ b/scripts/validate-catalog.test.mjs @@ -60,7 +60,7 @@ test("strict mode rejects stale reviewed bytes", () => { }), /bytes do not match/); }); -test("catalog versions match the package-index release anchor", () => { +test("Store candidate reconciles versions with the published Package Index catalog", () => { assert.equal(catalog.sequence, packageIndexRelease.store_sequence); for (const [packageId, version] of Object.entries(packageIndexRelease.packages)) { const listing = catalog.listings.find((item) => item.id === packageId); @@ -68,3 +68,30 @@ test("catalog versions match the package-index release anchor", () => { assert.equal(listing.distribution.version, version); } }); + +test("candidate accepts a new sequence while preserving the historical signature", () => { + const bytes = Buffer.from(JSON.stringify(catalog)); + assert.equal(validateCatalog(catalog, envelope, { candidate: true, catalogBytes: bytes }).sequence, 14); + assert.throws(() => validateCatalog(catalog, envelope, { strictEnvelope: true, catalogBytes: bytes }), /bytes do not match/); +}); + +test("candidate rejects same-sequence changes and rollback", () => { + const previous = JSON.parse(Buffer.from(envelope.bytes, "base64")); + for (const sequence of [previous.sequence, previous.sequence - 1]) { + const candidate = { ...catalog, sequence }; + assert.throws(() => validateCatalog(candidate, envelope, { + candidate: true, catalogBytes: Buffer.from(JSON.stringify(candidate)), + }), /advance the signed sequence/); + } +}); + +test("candidate accepts exact already-signed bytes and still rejects signature tampering", () => { + const bytes = Buffer.from(envelope.bytes, "base64"); + const previous = JSON.parse(bytes); + validateCatalog(previous, envelope, { candidate: true, catalogBytes: bytes }); + const invalid = structuredClone(envelope); + invalid.signatures.signatures[0].signature = Buffer.alloc(64).toString("base64"); + assert.throws(() => validateCatalog(catalog, invalid, { + candidate: true, catalogBytes: Buffer.from(JSON.stringify(catalog)), + }), /signature does not verify/); +});