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 }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..a9e295e --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,34 @@ +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: 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 diff --git a/README.md b/README.md index c43e40f..cde403c 100644 --- a/README.md +++ b/README.md @@ -11,7 +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, 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 +``` + +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 +``` diff --git a/scripts/check-release-sequence.mjs b/scripts/check-release-sequence.mjs new file mode 100644 index 0000000..794c936 --- /dev/null +++ b/scripts/check-release-sequence.mjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node +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}`); +console.log(`Catalog sequence ${current} is monotonic after ${latest || "no prior release"}.`); diff --git a/scripts/dry-run.mjs b/scripts/dry-run.mjs new file mode 100644 index 0000000..eb0a765 --- /dev/null +++ b/scripts/dry-run.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { generateKeyPairSync, sign, verify } from "node:crypto"; +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.`); diff --git a/scripts/validate-catalog.mjs b/scripts/validate-catalog.mjs new file mode 100644 index 0000000..80ecc76 --- /dev/null +++ b/scripts/validate-catalog.mjs @@ -0,0 +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"; + +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 }; +} + +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.`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); 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/); +});