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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions catalog.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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": []
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
6 changes: 5 additions & 1 deletion scripts/fixtures/package-index-release.v1.json
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down
12 changes: 11 additions & 1 deletion scripts/validate-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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.`);
}

Expand Down
29 changes: 28 additions & 1 deletion scripts/validate-catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,38 @@ 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);
assert.ok(listing, `${packageId} is advertised by the package-index release`);
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/);
});
Loading