From ce4ae602626e81fe5d8e165d10ae3e2ca49f4af4 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:28:52 +0300 Subject: [PATCH 01/12] Add secret-free Store catalog validator --- scripts/validate-catalog.mjs | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 scripts/validate-catalog.mjs diff --git a/scripts/validate-catalog.mjs b/scripts/validate-catalog.mjs new file mode 100644 index 0000000..2917010 --- /dev/null +++ b/scripts/validate-catalog.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; + +const strictEnvelope = process.argv.includes("--strict-envelope"); +const catalogBytes = await readFile(new URL("../catalog.json", import.meta.url)); +const catalog = JSON.parse(catalogBytes); +if (catalog.schema_version !== 1 || !Number.isSafeInteger(catalog.sequence) || catalog.sequence < 1) throw new Error("invalid schema_version or sequence"); +const issued = Date.parse(catalog.issued_at); +const expires = Date.parse(catalog.expires_at); +if (!Number.isFinite(issued) || !Number.isFinite(expires) || expires <= issued || expires - issued > 366 * 86400000) throw new Error("invalid catalog validity window"); +if (!Array.isArray(catalog.listings) || catalog.listings.length === 0) throw new Error("listings must be non-empty"); + +const ids = new Set(); +for (const listing of catalog.listings) { + if (!listing || typeof listing.id !== "string" || ids.has(listing.id)) throw new Error("duplicate or invalid listing id"); + ids.add(listing.id); + for (const field of ["kind", "name", "publisher", "description"]) if (typeof listing[field] !== "string" || !listing[field]) throw new Error(`${listing.id}: ${field} is required`); + if (!Array.isArray(listing.categories) || listing.categories.length === 0 || !Array.isArray(listing.availability?.platforms) || listing.availability.platforms.length === 0) throw new Error(`${listing.id}: categories/platforms are required`); + if (listing.icon_url !== null && !/^https:\/\//.test(listing.icon_url)) throw new Error(`${listing.id}: icon_url must be HTTPS or null`); + if (!Array.isArray(listing.screenshots) || listing.screenshots.some((url) => typeof url !== "string" || !/^https:\/\//.test(url))) throw new Error(`${listing.id}: screenshots must be HTTPS`); + const distribution = listing.distribution; + if (!distribution || typeof distribution !== "object") throw new Error(`${listing.id}: distribution is required`); + if (listing.kind === "external-app") { + if (typeof distribution.official_url !== "string" || !/^https:\/\//.test(distribution.official_url)) throw new Error(`${listing.id}: external app official_url is required`); + } else if (typeof distribution.package_id !== "string" || typeof distribution.version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(distribution.version)) { + throw new Error(`${listing.id}: package distribution requires package_id and semver version`); + } +} + +const envelope = JSON.parse(await readFile(new URL("../catalog.envelope.json", import.meta.url), "utf8")); +if (typeof envelope.bytes !== "string" || !envelope.signatures || envelope.signatures.schema_version !== 1 || !Array.isArray(envelope.signatures.signatures) || envelope.signatures.signatures.length === 0) throw new Error("invalid committed envelope shape"); +const envelopeBytes = Buffer.from(envelope.bytes, "base64"); +let envelopeCatalog; +try { envelopeCatalog = JSON.parse(envelopeBytes); } catch { throw new Error("envelope bytes are not JSON"); } +if (envelopeCatalog.schema_version !== 1 || !Number.isSafeInteger(envelopeCatalog.sequence)) throw new Error("envelope payload is not a catalog"); +if (strictEnvelope && Buffer.compare(envelopeBytes, catalogBytes) !== 0) throw new Error("committed envelope bytes do not match catalog.json"); +if (!strictEnvelope && Buffer.compare(envelopeBytes, catalogBytes) !== 0) console.warn("warning: committed envelope is a previous signed catalog; publication must regenerate it"); +for (const signature of envelope.signatures.signatures) { + if (typeof signature.key_id !== "string" || signature.algorithm !== "ed25519" || typeof signature.signature !== "string") throw new Error("invalid signature record"); +} +console.log(`Validated catalog sequence ${catalog.sequence} with ${catalog.listings.length} listings.`); From 6830d3aa0902ac6d7116b8b08df48716479f227a Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:28:53 +0300 Subject: [PATCH 02/12] Add Store catalog PR quality gate --- .github/workflows/quality.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/quality.yml diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..cef3db5 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,28 @@ +name: Store quality + +on: + pull_request: + paths: + - "catalog.json" + - "catalog.envelope.json" + - "scripts/**" + - ".github/workflows/**" + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: store-quality-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Validate catalog without secrets + run: node scripts/validate-catalog.mjs + - name: Check signing script syntax + run: node --check scripts/sign-catalog.mjs From 25652763c70d1bc7587bc33dfe336714b444ee91 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:29:01 +0300 Subject: [PATCH 03/12] Document secret-free catalog validation --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index c43e40f..b751784 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,17 @@ workflow. The private key exists only in the `STORE_SIGNING_KEY` repository secret. The checked-in `catalog.envelope.json` intentionally remains the previous signed envelope until that CI secret is available; do not generate a production signature locally. + +## Secret-free validation + +Pull requests run a validator that checks catalog schema, unique identities, +valid package/external distributions, HTTPS URLs, validity windows, and the +committed envelope shape without accessing `STORE_SIGNING_KEY`: + +```powershell +node scripts/validate-catalog.mjs +``` + +After signing, publication operators can additionally require the envelope +payload to match the checked-in catalog bytes with +`node scripts/validate-catalog.mjs --strict-envelope`. From 1f43c0d190ff8cca909616196899ed928e5d38f1 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:41:36 +0300 Subject: [PATCH 04/12] feat(store): validate replacements identities and signatures --- scripts/validate-catalog.mjs | 125 +++++++++++++++++++++++++---------- 1 file changed, 91 insertions(+), 34 deletions(-) diff --git a/scripts/validate-catalog.mjs b/scripts/validate-catalog.mjs index 2917010..80ecc76 100644 --- a/scripts/validate-catalog.mjs +++ b/scripts/validate-catalog.mjs @@ -1,41 +1,98 @@ #!/usr/bin/env node import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createPublicKey, verify } from "node:crypto"; -const strictEnvelope = process.argv.includes("--strict-envelope"); -const catalogBytes = await readFile(new URL("../catalog.json", import.meta.url)); -const catalog = JSON.parse(catalogBytes); -if (catalog.schema_version !== 1 || !Number.isSafeInteger(catalog.sequence) || catalog.sequence < 1) throw new Error("invalid schema_version or sequence"); -const issued = Date.parse(catalog.issued_at); -const expires = Date.parse(catalog.expires_at); -if (!Number.isFinite(issued) || !Number.isFinite(expires) || expires <= issued || expires - issued > 366 * 86400000) throw new Error("invalid catalog validity window"); -if (!Array.isArray(catalog.listings) || catalog.listings.length === 0) throw new Error("listings must be non-empty"); - -const ids = new Set(); -for (const listing of catalog.listings) { - if (!listing || typeof listing.id !== "string" || ids.has(listing.id)) throw new Error("duplicate or invalid listing id"); - ids.add(listing.id); - for (const field of ["kind", "name", "publisher", "description"]) if (typeof listing[field] !== "string" || !listing[field]) throw new Error(`${listing.id}: ${field} is required`); - if (!Array.isArray(listing.categories) || listing.categories.length === 0 || !Array.isArray(listing.availability?.platforms) || listing.availability.platforms.length === 0) throw new Error(`${listing.id}: categories/platforms are required`); - if (listing.icon_url !== null && !/^https:\/\//.test(listing.icon_url)) throw new Error(`${listing.id}: icon_url must be HTTPS or null`); - if (!Array.isArray(listing.screenshots) || listing.screenshots.some((url) => typeof url !== "string" || !/^https:\/\//.test(url))) throw new Error(`${listing.id}: screenshots must be HTTPS`); - const distribution = listing.distribution; - if (!distribution || typeof distribution !== "object") throw new Error(`${listing.id}: distribution is required`); - if (listing.kind === "external-app") { - if (typeof distribution.official_url !== "string" || !/^https:\/\//.test(distribution.official_url)) throw new Error(`${listing.id}: external app official_url is required`); - } else if (typeof distribution.package_id !== "string" || typeof distribution.version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(distribution.version)) { - throw new Error(`${listing.id}: package distribution requires package_id and semver version`); +export const PRODUCTION_PUBLIC_KEY = "it14mzPjoqdgaHXdCDIjCoUgGXf/f5izJrGRUuk3o/A="; +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); +const semver = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const https = /^https:\/\//; +const compatibilityRange = /^(?:[<>=~^*]|\d)/; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function validateReplacementChains(listings) { + const byId = new Map(listings.map((listing) => [listing.id, listing])); + const packageIds = new Set(); + for (const listing of listings) { + const distribution = listing.distribution; + if (listing.kind !== "external-app") { + assert(typeof distribution.package_id === "string" && semver.test(distribution.version), `${listing.id}: package distribution requires package_id and semver version`); + assert(!packageIds.has(distribution.package_id), `duplicate package identity: ${distribution.package_id}`); + packageIds.add(distribution.package_id); + } + if (listing.connects_to !== null) { + assert(typeof listing.connects_to === "string" && byId.has(listing.connects_to), `${listing.id}: connects_to must reference an existing listing`); + } + if (listing.replacement_id !== undefined) { + assert(typeof listing.replacement_id === "string" && listing.replacement_id !== listing.id && byId.has(listing.replacement_id), `${listing.id}: invalid replacement_id`); + const seen = new Set([listing.id]); + let next = listing.replacement_id; + while (next) { + assert(!seen.has(next), `${listing.id}: replacement cycle`); + seen.add(next); + next = byId.get(next)?.replacement_id; + } + } + } +} + +export function validateCatalog(catalog, envelope, { strictEnvelope = false } = {}) { + assert(catalog && catalog.schema_version === 1, "invalid schema_version"); + assert(Number.isSafeInteger(catalog.sequence) && catalog.sequence > 0, "invalid sequence"); + const issued = Date.parse(catalog.issued_at); + const expires = Date.parse(catalog.expires_at); + assert(Number.isFinite(issued) && Number.isFinite(expires) && expires > issued && expires - issued <= 366 * 86400000, "invalid catalog validity window"); + assert(Array.isArray(catalog.listings) && catalog.listings.length > 0, "listings must be non-empty"); + + const ids = new Set(); + for (const listing of catalog.listings) { + assert(listing && typeof listing.id === "string" && !ids.has(listing.id), "duplicate or invalid listing id"); + ids.add(listing.id); + for (const field of ["kind", "name", "publisher", "description"]) assert(typeof listing[field] === "string" && listing[field], `${listing.id}: ${field} is required`); + assert(Array.isArray(listing.categories) && listing.categories.length > 0, `${listing.id}: categories are required`); + assert(Array.isArray(listing.availability?.platforms) && listing.availability.platforms.length > 0, `${listing.id}: platforms are required`); + assert(listing.icon_url === null || (typeof listing.icon_url === "string" && https.test(listing.icon_url)), `${listing.id}: icon_url must be HTTPS or null`); + assert(Array.isArray(listing.screenshots) && listing.screenshots.every((url) => typeof url === "string" && https.test(url)), `${listing.id}: screenshots must be HTTPS`); + const distribution = listing.distribution; + assert(distribution && typeof distribution === "object", `${listing.id}: distribution is required`); + if (listing.kind === "external-app") { + assert(typeof distribution.official_url === "string" && https.test(distribution.official_url), `${listing.id}: external app official_url is required`); + } else { + assert(typeof distribution.package_id === "string" && semver.test(distribution.version), `${listing.id}: package distribution requires package_id and semver version`); + } + for (const item of listing.data_compatibility ?? []) { + assert(item && typeof item.type === "string" && typeof item.versions === "string" && compatibilityRange.test(item.versions), `${listing.id}: malformed data compatibility`); + } } + validateReplacementChains(catalog.listings); + + assert(envelope && typeof envelope.bytes === "string", "invalid committed envelope bytes"); + const envelopeBytes = Buffer.from(envelope.bytes, "base64"); + let envelopeCatalog; + try { envelopeCatalog = JSON.parse(envelopeBytes); } catch { throw new Error("envelope bytes are not JSON"); } + assert(envelopeCatalog.schema_version === 1 && Number.isSafeInteger(envelopeCatalog.sequence), "envelope payload is not a catalog"); + if (strictEnvelope) assert(Buffer.compare(envelopeBytes, Buffer.from(JSON.stringify(catalog, null, 2) + "\n")) === 0, "committed envelope bytes do not match catalog.json"); + assert(envelope.signatures?.schema_version === 1 && Array.isArray(envelope.signatures.signatures) && envelope.signatures.signatures.length > 0, "invalid signature records"); + const publicKey = createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(PRODUCTION_PUBLIC_KEY, "base64")]), format: "der", type: "spki" }); + for (const signature of envelope.signatures.signatures) { + assert(signature.key_id === "kosmos-store-2026" && signature.algorithm === "ed25519" && typeof signature.signature === "string", "invalid signature record"); + assert(verify(null, envelopeBytes, publicKey, Buffer.from(signature.signature, "base64")), "committed envelope signature does not verify"); + } + return { sequence: catalog.sequence, listings: catalog.listings.length }; } -const envelope = JSON.parse(await readFile(new URL("../catalog.envelope.json", import.meta.url), "utf8")); -if (typeof envelope.bytes !== "string" || !envelope.signatures || envelope.signatures.schema_version !== 1 || !Array.isArray(envelope.signatures.signatures) || envelope.signatures.signatures.length === 0) throw new Error("invalid committed envelope shape"); -const envelopeBytes = Buffer.from(envelope.bytes, "base64"); -let envelopeCatalog; -try { envelopeCatalog = JSON.parse(envelopeBytes); } catch { throw new Error("envelope bytes are not JSON"); } -if (envelopeCatalog.schema_version !== 1 || !Number.isSafeInteger(envelopeCatalog.sequence)) throw new Error("envelope payload is not a catalog"); -if (strictEnvelope && Buffer.compare(envelopeBytes, catalogBytes) !== 0) throw new Error("committed envelope bytes do not match catalog.json"); -if (!strictEnvelope && Buffer.compare(envelopeBytes, catalogBytes) !== 0) console.warn("warning: committed envelope is a previous signed catalog; publication must regenerate it"); -for (const signature of envelope.signatures.signatures) { - if (typeof signature.key_id !== "string" || signature.algorithm !== "ed25519" || typeof signature.signature !== "string") throw new Error("invalid signature record"); +async function main() { + const catalogPath = new URL("../catalog.json", import.meta.url); + const envelopePath = new URL("../catalog.envelope.json", import.meta.url); + 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") }); + console.log(`Validated catalog sequence ${result.sequence} with ${result.listings} listings.`); } -console.log(`Validated catalog sequence ${catalog.sequence} with ${catalog.listings.length} listings.`); + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); From eac7e0aa5494d245588a982cd4b808feb9a0f797 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:41:46 +0300 Subject: [PATCH 05/12] feat(store): add secret-free ephemeral signing dry-run --- scripts/dry-run.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 scripts/dry-run.mjs diff --git a/scripts/dry-run.mjs b/scripts/dry-run.mjs new file mode 100644 index 0000000..af00e23 --- /dev/null +++ b/scripts/dry-run.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { generateKeyPairSync, sign, verify } from "node:crypto"; +import { validateCatalog } from "./validate-catalog.mjs"; + +const catalogBytes = await readFile(new URL("../catalog.json", import.meta.url)); +const catalog = JSON.parse(catalogBytes); +const { privateKey, publicKey } = generateKeyPairSync("ed25519"); +const signature = sign(null, catalogBytes, privateKey); +const envelope = { + bytes: catalogBytes.toString("base64"), + signatures: { + schema_version: 1, + signatures: [{ key_id: "kosmos-store-2026", algorithm: "ed25519", signature: signature.toString("base64") }], + }, +}; +if (!verify(null, catalogBytes, publicKey, signature)) throw new Error("ephemeral signature self-check failed"); +console.log(`Dry-run signed and verified catalog sequence ${catalog.sequence}; no production key or release was used.`); From 3a592e7923f7aebcccc00b70fef905e50622f2d8 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:41:55 +0300 Subject: [PATCH 06/12] fix(store): keep dry-run independent of production trust --- scripts/dry-run.mjs | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/dry-run.mjs b/scripts/dry-run.mjs index af00e23..eb0a765 100644 --- a/scripts/dry-run.mjs +++ b/scripts/dry-run.mjs @@ -1,8 +1,6 @@ #!/usr/bin/env node import { readFile } from "node:fs/promises"; import { generateKeyPairSync, sign, verify } from "node:crypto"; -import { validateCatalog } from "./validate-catalog.mjs"; - const catalogBytes = await readFile(new URL("../catalog.json", import.meta.url)); const catalog = JSON.parse(catalogBytes); const { privateKey, publicKey } = generateKeyPairSync("ed25519"); From 740c549f89491f37dd6591d0a88e5755506a158e Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:42:12 +0300 Subject: [PATCH 07/12] test(store): cover catalog and envelope failure cases --- scripts/validate-catalog.test.mjs | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 scripts/validate-catalog.test.mjs diff --git a/scripts/validate-catalog.test.mjs b/scripts/validate-catalog.test.mjs new file mode 100644 index 0000000..f38a04e --- /dev/null +++ b/scripts/validate-catalog.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { validateCatalog } from "./validate-catalog.mjs"; + +const catalog = JSON.parse(await readFile(new URL("../catalog.json", import.meta.url), "utf8")); +const envelope = JSON.parse(await readFile(new URL("../catalog.envelope.json", import.meta.url), "utf8")); + +test("valid committed catalog and envelope", () => { + assert.deepEqual(validateCatalog(catalog, envelope), { sequence: catalog.sequence, listings: catalog.listings.length }); +}); + +test("duplicate IDs fail", () => { + const value = structuredClone(catalog); + value.listings.push(structuredClone(value.listings[0])); + assert.throws(() => validateCatalog(value, envelope), /duplicate|invalid listing id/); +}); + +test("replacement cycles fail", () => { + const value = structuredClone(catalog); + value.listings[0].replacement_id = value.listings[1].id; + value.listings[1].replacement_id = value.listings[0].id; + assert.throws(() => validateCatalog(value, envelope), /replacement cycle/); +}); + +test("invalid sequence fails", () => { + const value = structuredClone(catalog); + value.sequence = 0; + assert.throws(() => validateCatalog(value, envelope), /invalid sequence/); +}); + +test("wrong public-key signature fails", () => { + const value = structuredClone(envelope); + value.signatures.signatures[0].signature = Buffer.alloc(64).toString("base64"); + assert.throws(() => validateCatalog(catalog, value), /signature does not verify/); +}); + +test("altered envelope bytes fail signature verification", () => { + const value = structuredClone(envelope); + value.bytes = Buffer.from(JSON.stringify({ schema_version: 1, sequence: catalog.sequence })).toString("base64"); + assert.throws(() => validateCatalog(catalog, value), /signature does not verify/); +}); From d1fdfcd6e1a26389e54237f741bc7a1d763611ed Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:42:22 +0300 Subject: [PATCH 08/12] feat(store): add monotonic release sequence guard --- scripts/check-release-sequence.mjs | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 scripts/check-release-sequence.mjs diff --git a/scripts/check-release-sequence.mjs b/scripts/check-release-sequence.mjs new file mode 100644 index 0000000..356cb84 --- /dev/null +++ b/scripts/check-release-sequence.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +const current = Number(JSON.parse(await (await fetch(new URL("../catalog.json", import.meta.url))).text()).sequence); +const latest = Number(process.env.STORE_LATEST_SEQUENCE || 0); +if (!Number.isSafeInteger(current) || current < 1) throw new Error("catalog sequence must be a positive integer"); +if (latest && (!Number.isSafeInteger(latest) || current <= latest)) throw new Error(`catalog sequence ${current} is not greater than latest immutable sequence ${latest}`); +console.log(`Catalog sequence ${current} is monotonic after ${latest || "no prior release"}.`); From 16655b48a0502e0063f43463d19ae07b5c775819 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:42:34 +0300 Subject: [PATCH 09/12] fix(store): read catalog locally in sequence guard --- scripts/check-release-sequence.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/check-release-sequence.mjs b/scripts/check-release-sequence.mjs index 356cb84..794c936 100644 --- a/scripts/check-release-sequence.mjs +++ b/scripts/check-release-sequence.mjs @@ -1,5 +1,7 @@ #!/usr/bin/env node -const current = Number(JSON.parse(await (await fetch(new URL("../catalog.json", import.meta.url))).text()).sequence); +import { readFile } from "node:fs/promises"; + +const current = Number(JSON.parse(await readFile(new URL("../catalog.json", import.meta.url), "utf8")).sequence); const latest = Number(process.env.STORE_LATEST_SEQUENCE || 0); if (!Number.isSafeInteger(current) || current < 1) throw new Error("catalog sequence must be a positive integer"); if (latest && (!Number.isSafeInteger(latest) || current <= latest)) throw new Error(`catalog sequence ${current} is not greater than latest immutable sequence ${latest}`); From e8e0d0d4576375e1ffd12bd91a523020f635c0e8 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:42:48 +0300 Subject: [PATCH 10/12] ci(store): run validator fixtures dry-run and actionlint --- .github/workflows/quality.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index cef3db5..a9e295e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -24,5 +24,11 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Validate catalog without secrets run: node scripts/validate-catalog.mjs + - name: Validate catalog fixtures + run: node --test scripts/validate-catalog.test.mjs + - name: Run ephemeral signing dry-run + run: node scripts/dry-run.mjs + - name: Check Actions syntax + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2 - name: Check signing script syntax run: node --check scripts/sign-catalog.mjs From cac5e1e8832ed1b0afaa449e0018799fa2991025 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:44:12 +0300 Subject: [PATCH 11/12] Guard immutable catalog releases before signing --- .github/workflows/publish.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d29e848..566b83a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,10 +14,23 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 + - name: Validate release sequence and immutable tag + env: + GH_TOKEN: ${{ github.token }} + run: | + sequence="$(node -p "require('./catalog.json').sequence")" + if gh release view "catalog-${sequence}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + echo "catalog-${sequence} already exists; immutable releases cannot be replaced" + exit 1 + fi + latest="$(gh release list --repo "${GITHUB_REPOSITORY}" --limit 100 --json tagName --jq '.[].tagName' | sed -n 's/^catalog-\([0-9][0-9]*\)$/\1/p' | sort -n | tail -1)" + STORE_LATEST_SEQUENCE="${latest:-0}" node scripts/check-release-sequence.mjs - name: Sign catalog run: node scripts/sign-catalog.mjs env: STORE_SIGNING_KEY: ${{ secrets.STORE_SIGNING_KEY }} + - name: Verify signed envelope + run: node scripts/validate-catalog.mjs --strict-envelope - name: Publish immutable release env: GH_TOKEN: ${{ github.token }} From b6a14dd3daca384dfb2992fe683b2f18ae40f9d1 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:44:24 +0300 Subject: [PATCH 12/12] Document dry-run and immutable release contract --- README.md | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b751784..cde403c 100644 --- a/README.md +++ b/README.md @@ -11,21 +11,40 @@ Production trust: - stable URL: `https://github.com/makekosmos/store/releases/latest/download/catalog.envelope.json` Edit `catalog.json`, increment `sequence`, then run the `Publish catalog` -workflow. The private key exists only in the `STORE_SIGNING_KEY` repository -secret. The checked-in `catalog.envelope.json` intentionally remains the -previous signed envelope until that CI secret is available; do not generate a -production signature locally. +workflow. CI rejects an existing `catalog-N` release and requires the new +sequence to be greater than every prior immutable catalog release before it +uses the signing secret. The private key exists only in the +`STORE_SIGNING_KEY` repository secret. The checked-in +`catalog.envelope.json` intentionally remains the previous signed envelope +until that CI secret is available; do not generate a production signature +locally. + +Key rotation changes the `key_id`, public-key allowlist, and release +documentation together. Historical envelopes remain verifiable under their +original key; never overwrite a published tag. ## Secret-free validation Pull requests run a validator that checks catalog schema, unique identities, -valid package/external distributions, HTTPS URLs, validity windows, and the -committed envelope shape without accessing `STORE_SIGNING_KEY`: +valid package/external distributions, HTTPS URLs, validity windows, replacement +references/cycles, and the committed envelope signature without accessing +`STORE_SIGNING_KEY`: ```powershell node scripts/validate-catalog.mjs +node --test scripts/validate-catalog.test.mjs ``` -After signing, publication operators can additionally require the envelope -payload to match the checked-in catalog bytes with -`node scripts/validate-catalog.mjs --strict-envelope`. +The dry-run path exercises ephemeral Ed25519 signing without publishing or +using production credentials: + +```powershell +node scripts/dry-run.mjs +``` + +After signing, publication operators require the envelope payload to match the +catalog bytes with: + +```powershell +node scripts/validate-catalog.mjs --strict-envelope +```