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
76 changes: 76 additions & 0 deletions .github/workflows/sign-catalog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Sign catalog

# Publishes catalog.signed.json, the catalog srelens hosts read since srelens/srelens#559.
# A host trusts it only when the catalog key its pinned root names has signed it, before it
# expires, and at a version higher than any it has seen, so this signs catalog.json:
#
# - whenever the catalog, a publisher delegation, the root or the signing scripts change;
# - every week, well inside the 30-day expiry, so an unchanged catalog stays current;
# - on demand.
#
# Before committing, it checks the result against trust/root.json, the root hosts pin
# (scripts/verify-catalog.mjs), and it never publishes a catalog that check refuses.
# catalog.json itself stays as it is, for hosts released before srelens/srelens#559.
on:
push:
branches: [main]
paths:
- catalog.json
- publishers/**
- trust/**
- scripts/trust.mjs
- scripts/verify-catalog.mjs
- .github/workflows/sign-catalog.yml
schedule:
- cron: "23 5 * * 1"
workflow_dispatch:

permissions:
contents: read

# One signing at a time, each finishing: two runs racing could publish out of order.
concurrency:
group: sign-catalog
cancel-in-progress: false

jobs:
sign:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Sign catalog.json
env:
CATALOG_SIGNING_PRIVATE_KEY: ${{ secrets.CATALOG_SIGNING_PRIVATE_KEY }}
run: |
if [ -z "$CATALOG_SIGNING_PRIVATE_KEY" ]; then
echo "::error::CATALOG_SIGNING_PRIVATE_KEY is not set; refusing to publish an unsigned catalog"
exit 1
fi
# trust.mjs reads the key from a file: one only this job can read, gone when the step ends.
key="$RUNNER_TEMP/catalog.pem"
trap 'rm -f "$key"' EXIT
(umask 077; printf '%s\n' "$CATALOG_SIGNING_PRIVATE_KEY" > "$key")
git show HEAD:catalog.signed.json > "$RUNNER_TEMP/previous.json" 2>/dev/null || rm -f "$RUNNER_TEMP/previous.json"
publishers=()
for delegation in publishers/*.json; do publishers+=(--publisher "$delegation"); done
# The version is the time of signing: higher on every run, so hosts take each new one.
node scripts/trust.mjs catalog --in catalog.json "${publishers[@]}" \
--version "$(date -u +%s)" \
--expires "$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)" \
--sign "$key" > catalog.signed.json
- name: Check it as a srelens host would
run: node scripts/verify-catalog.mjs catalog.signed.json --previous "$RUNNER_TEMP/previous.json"
- name: Publish catalog.signed.json
run: |
version=$(node -e 'const e = JSON.parse(require("fs").readFileSync("catalog.signed.json")); console.log(JSON.parse(Buffer.from(e.payload, "base64")).version)')
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add catalog.signed.json
git commit -m "chore(catalog): sign catalog version $version"
# Onto whatever reached main meanwhile. Only this workflow writes catalog.signed.json,
# and one run at a time, so the rebase meets no conflict; a commit that changed
# catalog.json has its own run queued behind this one, at a higher version.
git pull --rebase --quiet origin main
git push
44 changes: 40 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ exact tested host commit. Install through a compatible desktop host's app catalo
and review the requested read and write permissions.

Released manifests carry detached Ed25519 publisher signatures. The host checks
the catalog checksum, trusted publisher key and API compatibility before
installation. Catalog metadata itself does not authorize actions.
the catalog checksum, the publisher key the catalog delegates the app's namespace
to, and API compatibility before installation. Catalog metadata itself does not
authorize actions.

## Add or update an extension

Expand All @@ -33,18 +34,53 @@ installation. Catalog metadata itself does not authorize actions.
4. Run `python3 scripts/catalog.py`, `python3 scripts/catalog.py --check`, and
`python3 -m unittest discover -s tests`.
5. Submit a PR with validation evidence and any compatibility/permission changes.
Leave `catalog.signed.json` alone: CI signs it once the PR merges.

Catalog validation is offline and does not download or execute contributor code.
Reviewers must verify repository ownership, release provenance, permissions and
asset contents before accepting an entry. A checksum identifies exact bytes; it
is not a signature or an automatic trust decision. Catalog inclusion never grants
permissions, connects clusters, or bypasses app installation consent.

## Signed catalog

srelens hosts from [srelens/srelens#559](https://github.com/srelens/srelens/issues/559)
on read `catalog.signed.json`, not `catalog.json`. It is a
[DSSE](https://github.com/secure-systems-lab/dsse) envelope over the same entries,
signed with the catalog key, and it carries the publisher delegations: which key may
sign which app-ID namespace. A host trusts it only when the catalog key named by the
root it pins signed it, before its `expires`, and at a `version` higher than any it
has seen. Otherwise it keeps the last catalog it verified.

- `trust/root.json` is a copy of the root the host pins
(`crates/registry/src/extensions/trust/root.json` in srelens/srelens). It names the
catalog key; nothing here can change which key that is.
- `publishers/<id>.json` are the delegations, each signed once with the catalog key.
`publishers/srelens.json` delegates `org.srelens` to the srelens release key.
- `scripts/trust.mjs` is the host's signing script
(`scripts/extensions/trust.mjs` in srelens/srelens), copied here.
- `.github/workflows/sign-catalog.yml` signs `catalog.json` into
`catalog.signed.json` whenever the catalog, a delegation, the root or the scripts
change on `main`, and every Monday, with the signing time as the version and a
30-day expiry. It checks the result with `scripts/verify-catalog.mjs` before
committing it and publishes nothing that check refuses. The catalog key is the
`CATALOG_SIGNING_PRIVATE_KEY` secret; the root keys are never in CI.

`catalog.json` stays for hosts released before #559, which read only it and only
release signatures in the bare 64-byte form. Every release it lists today is signed
that way. A release whose `manifest.json.sig` names its key (`{"keyid","sig"}`) is
readable only by #559 hosts, so before one is listed, `catalog.json` has to be frozen
and the signed catalog given its own source.

Adding a publisher, the key ceremony and the formats are in
[`docs/extensions/trust.md`](https://github.com/srelens/srelens/blob/dev/docs/extensions/trust.md)
in srelens/srelens.

## Ownership boundaries

- `srelens/srelens`: runtime, manifest API, host UI, permission enforcement and backend settings.
- This repository: discovery JSON and catalog validation.
- Extension repositories: manifests, integration tests, documentation and releases.

Publisher-key delegation, rotation and permission-diff consent on updates are
tracked in the host's extension-platform roadmap.
Key rotation, revocation and permission-diff consent on updates are tracked in the
host's extension-platform roadmap.
10 changes: 10 additions & 0 deletions publishers/srelens.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"payloadType": "application/vnd.srelens.publisher+json",
"payload": "ewogICJfdHlwZSI6ICJwdWJsaXNoZXIiLAogICJ2ZXJzaW9uIjogMSwKICAiaWQiOiAic3JlbGVucyIsCiAgIm5hbWUiOiAic3JlbGVucyIsCiAgImtleXMiOiBbCiAgICB7CiAgICAgICJrZXl0eXBlIjogImVkMjU1MTkiLAogICAgICAic2NoZW1lIjogImVkMjU1MTkiLAogICAgICAia2V5dmFsIjogewogICAgICAgICJwdWJsaWMiOiAiMGRhNGZiOGM5M2NhMmY4MTRmYjIxZDI0OWQzYmNmMGE5NjNhYmRmZjE3ZTY4YTg2YjJlYWFlZWU2ZDY5ODdlNSIKICAgICAgfQogICAgfQogIF0sCiAgIm5hbWVzcGFjZXMiOiBbCiAgICAib3JnLnNyZWxlbnMiCiAgXQp9Cg==",
"signatures": [
{
"keyid": "5fe7412b6449372a315109f192c89d9146bfc83cc7cf1b2bd0ba531222a1b062",
"sig": "0eUWs/MK7/vnOlAWEgUp5sUyfGyOWMoJVMBwO1gYsHS4dSrnnehCv018lUYM0lIa5A2QmrCZBiwoDDp9jwYtAQ=="
}
]
}
211 changes: 211 additions & 0 deletions scripts/trust.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
#!/usr/bin/env node
// Signs what srelens hosts verify before they trust an app (#559): the root, publisher
// delegations, the catalog, and release signatures. The format is described in
// docs/extensions/trust.md and checked by crates/registry/src/extensions/trust.rs; the test
// fixtures in crates/registry/tests/fixtures/trust are this script's output.
//
// Dependency-free: node:crypto signs Ed25519, as the app release workflows' sign.mjs does.
//
// node scripts/extensions/trust.mjs keygen <private.pem>
// node scripts/extensions/trust.mjs key <key>
// node scripts/extensions/trust.mjs root --version N --root <key>... --root-threshold N \
// --catalog <key>... --catalog-threshold N --sign <key>...
// node scripts/extensions/trust.mjs publisher --id ID --name NAME --key <key>... \
// --namespace NS... [--version N] --sign <key>...
// node scripts/extensions/trust.mjs bundle <publisher.json>...
// node scripts/extensions/trust.mjs catalog --in <catalog.json> --publisher <publisher.json>... \
// --version N --expires <RFC 3339 UTC> --sign <key>...
// node scripts/extensions/trust.mjs release --sign <key> <manifest.json>
//
// A <key> is a PEM file (a PKCS#8 private key, or an SPKI public key where only the public
// half is needed), a file of the 32 raw public key bytes, or `seed:<64 hex>`: a private key
// derived from a seed, for test fixtures only, since anyone who reads the seed holds the key.
// Signed documents are written to stdout.
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';

const TYPES = {
root: 'application/vnd.srelens.root+json',
catalog: 'application/vnd.srelens.catalog+json',
publisher: 'application/vnd.srelens.publisher+json',
};
// DER prefixes of an Ed25519 key: PKCS#8 around a 32-byte seed, SPKI around a public key.
const PKCS8_SEED_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');

function fail(message) {
console.error(`trust.mjs: ${message}`);
process.exit(1);
}

/** A key argument as `{ privateKey?, publicKey }` KeyObjects. */
function loadKey(spec) {
if (spec.startsWith('seed:')) {
const seed = Buffer.from(spec.slice(5), 'hex');
if (seed.length !== 32) fail(`${spec}: a seed is 64 hexadecimal characters`);
const privateKey = createPrivateKey({ key: Buffer.concat([PKCS8_SEED_PREFIX, seed]), format: 'der', type: 'pkcs8' });
return { privateKey, publicKey: createPublicKey(privateKey) };
}
const raw = readFileSync(spec);
if (raw.subarray(0, 10).toString() === '-----BEGIN') {
const text = raw.toString();
if (text.includes('PRIVATE KEY')) {
const privateKey = createPrivateKey(text);
if (privateKey.asymmetricKeyType !== 'ed25519') fail(`${spec}: not an Ed25519 key`);
return { privateKey, publicKey: createPublicKey(privateKey) };
}
const publicKey = createPublicKey(text);
if (publicKey.asymmetricKeyType !== 'ed25519') fail(`${spec}: not an Ed25519 key`);
return { publicKey };
}
if (raw.length !== 32) fail(`${spec}: neither a PEM key nor 32 raw public key bytes`);
return { publicKey: createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' }) };
}

function rawPublic(key) {
return key.publicKey.export({ format: 'der', type: 'spki' }).subarray(SPKI_PREFIX.length);
}

/** SHA-256 of the raw public key, as the host computes it. */
function keyId(key) {
return createHash('sha256').update(rawPublic(key)).digest('hex');
}

function keySpec(key) {
return { keytype: 'ed25519', scheme: 'ed25519', keyval: { public: rawPublic(key).toString('hex') } };
}

/** DSSE's pre-authentication encoding: what the signature covers. */
function pae(type, body) {
return Buffer.concat([Buffer.from(`DSSEv1 ${Buffer.byteLength(type)} ${type} ${body.length} `), body]);
}

function envelope(type, document, signers) {
if (signers.length === 0) fail('name at least one --sign key');
const body = Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
const message = pae(type, body);
return {
payloadType: type,
payload: body.toString('base64'),
signatures: signers.map((spec) => {
const key = loadKey(spec);
if (!key.privateKey) fail(`${spec}: signing needs the private key`);
return { keyid: keyId(key), sig: sign(null, message, key.privateKey).toString('base64') };
}),
};
}

function print(value) {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}

/** `--name value` pairs, repeatable, and the positional arguments. */
function parse(args) {
const options = {};
const positional = [];
for (let i = 0; i < args.length; i++) {
if (!args[i].startsWith('--')) {
positional.push(args[i]);
continue;
}
const name = args[i].slice(2);
if (i + 1 >= args.length) fail(`--${name} needs a value`);
(options[name] ??= []).push(args[++i]);
}
return { options, positional };
}

function one(options, name) {
const values = options[name] ?? [];
if (values.length !== 1) fail(`give --${name} exactly once`);
return values[0];
}

function positiveInteger(options, name, fallback) {
const text = options[name] ? one(options, name) : fallback;
const value = Number(text);
if (!Number.isSafeInteger(value) || value < 1) fail(`--${name} must be a whole number of at least 1`);
return value;
}

function role(options, name) {
const keys = (options[name] ?? []).map(loadKey);
if (keys.length === 0) fail(`name at least one --${name} key`);
return { keys, threshold: positiveInteger(options, `${name}-threshold`) };
}

const commands = {
keygen({ positional: [out] }) {
if (!out) fail('keygen <private.pem>');
if (existsSync(out)) fail(`${out} exists; a key is never overwritten`);
const { privateKey } = generateKeyPairSync('ed25519');
// Never overwrites: a key that is replaced by accident cannot be recovered.
writeFileSync(out, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600, flag: 'wx' });
commands.key({ positional: [out] });
},
key({ positional: [spec] }) {
if (!spec) fail('key <key>');
const key = loadKey(spec);
print({ keyid: keyId(key), key: keySpec(key) });
},
root({ options }) {
const root = role(options, 'root');
const catalog = role(options, 'catalog');
const keys = {};
for (const key of [...root.keys, ...catalog.keys]) keys[keyId(key)] = keySpec(key);
const document = {
_type: 'root',
version: positiveInteger(options, 'version'),
keys,
roles: {
root: { keyids: root.keys.map(keyId), threshold: root.threshold },
catalog: { keyids: catalog.keys.map(keyId), threshold: catalog.threshold },
},
};
print(envelope(TYPES.root, document, options.sign ?? []));
},
publisher({ options }) {
const document = {
_type: 'publisher',
version: positiveInteger(options, 'version', '1'),
id: one(options, 'id'),
name: one(options, 'name'),
keys: (options.key ?? []).map((spec) => keySpec(loadKey(spec))),
namespaces: options.namespace ?? [],
};
print(envelope(TYPES.publisher, document, options.sign ?? []));
},
bundle({ positional }) {
print(positional.map((file) => JSON.parse(readFileSync(file, 'utf8'))));
},
catalog({ options }) {
const source = JSON.parse(readFileSync(one(options, 'in'), 'utf8'));
const expires = one(options, 'expires');
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(expires) || Number.isNaN(Date.parse(expires))) {
fail('--expires must be an RFC 3339 UTC time such as 2026-10-31T00:00:00Z');
}
const document = {
_type: 'catalog',
schemaVersion: 2,
version: positiveInteger(options, 'version'),
expires,
publishers: (options.publisher ?? []).map((file) => JSON.parse(readFileSync(file, 'utf8'))),
// An unsigned catalog of schema version 1 gives its entries unchanged.
extensions: source.extensions ?? [],
};
print(envelope(TYPES.catalog, document, options.sign ?? []));
},
release({ options, positional: [manifest] }) {
if (!manifest) fail('release --sign <key> <manifest.json>');
const key = loadKey(one(options, 'sign'));
if (!key.privateKey) fail('signing needs the private key');
// Over the exact manifest bytes, as every signature before #559 was; only the file
// around it changed, so it can name its key.
const sig = sign(null, readFileSync(manifest), key.privateKey);
print({ keyid: keyId(key), sig: sig.toString('base64') });
},
};

const [command, ...rest] = process.argv.slice(2);
if (!commands[command]) fail(`unknown command ${command ?? '(none)'}; see the header of this file`);
commands[command](parse(rest));
Loading
Loading