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
13 changes: 13 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -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
41 changes: 37 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
8 changes: 8 additions & 0 deletions scripts/check-release-sequence.mjs
Original file line number Diff line number Diff line change
@@ -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"}.`);
16 changes: 16 additions & 0 deletions scripts/dry-run.mjs
Original file line number Diff line number Diff line change
@@ -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.`);
98 changes: 98 additions & 0 deletions scripts/validate-catalog.mjs
Original file line number Diff line number Diff line change
@@ -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();
42 changes: 42 additions & 0 deletions scripts/validate-catalog.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading