From a4c26cf9ebb4812bd8eca133b88d5e0b30234e03 Mon Sep 17 00:00:00 2001 From: Devesh Kumar Date: Sun, 27 Sep 2026 13:01:20 +0200 Subject: [PATCH] feat(catalog): publish a signed catalog for srelens hosts (srelens/srelens#559) srelens hosts from srelens/srelens#559 on no longer read catalog.json. They read catalog.signed.json: a DSSE envelope over the same entries, signed with the catalog key their pinned root names, carrying the publisher delegations, a version they refuse to see go backwards, and an expiry after which they stop offering installs. Nothing published it yet, so those hosts would keep no catalog at all. A new workflow signs catalog.json into catalog.signed.json with the CATALOG_SIGNING_PRIVATE_KEY secret whenever the catalog, a delegation, the root or the scripts change on main, and every Monday, using the signing time as the version and a 30-day expiry. Before committing, verify-catalog.mjs checks the result as a host would: the copied root signs itself at its threshold, the catalog and every delegation are signed by its catalog role, the version is higher than the published one, the expiry is ahead, and the entries are exactly catalog.json's. A catalog it refuses is not published. trust/root.json and scripts/trust.mjs are copies of the pinned root and the signing script in srelens/srelens. publishers/srelens.json delegates org.srelens to the srelens release key. catalog.json and its entries are unchanged, for hosts released before #559. --- .github/workflows/sign-catalog.yml | 76 +++++++++++ README.md | 44 +++++- publishers/srelens.json | 10 ++ scripts/trust.mjs | 211 +++++++++++++++++++++++++++++ scripts/verify-catalog.mjs | 114 ++++++++++++++++ trust/root.json | 14 ++ 6 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/sign-catalog.yml create mode 100644 publishers/srelens.json create mode 100644 scripts/trust.mjs create mode 100644 scripts/verify-catalog.mjs create mode 100644 trust/root.json diff --git a/.github/workflows/sign-catalog.yml b/.github/workflows/sign-catalog.yml new file mode 100644 index 0000000..b2fa3cc --- /dev/null +++ b/.github/workflows/sign-catalog.yml @@ -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 diff --git a/README.md b/README.md index f397893..5294a22 100644 --- a/README.md +++ b/README.md @@ -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 @@ -33,6 +34,7 @@ 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 @@ -40,11 +42,45 @@ 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/.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. diff --git a/publishers/srelens.json b/publishers/srelens.json new file mode 100644 index 0000000..31dd497 --- /dev/null +++ b/publishers/srelens.json @@ -0,0 +1,10 @@ +{ + "payloadType": "application/vnd.srelens.publisher+json", + "payload": "ewogICJfdHlwZSI6ICJwdWJsaXNoZXIiLAogICJ2ZXJzaW9uIjogMSwKICAiaWQiOiAic3JlbGVucyIsCiAgIm5hbWUiOiAic3JlbGVucyIsCiAgImtleXMiOiBbCiAgICB7CiAgICAgICJrZXl0eXBlIjogImVkMjU1MTkiLAogICAgICAic2NoZW1lIjogImVkMjU1MTkiLAogICAgICAia2V5dmFsIjogewogICAgICAgICJwdWJsaWMiOiAiMGRhNGZiOGM5M2NhMmY4MTRmYjIxZDI0OWQzYmNmMGE5NjNhYmRmZjE3ZTY4YTg2YjJlYWFlZWU2ZDY5ODdlNSIKICAgICAgfQogICAgfQogIF0sCiAgIm5hbWVzcGFjZXMiOiBbCiAgICAib3JnLnNyZWxlbnMiCiAgXQp9Cg==", + "signatures": [ + { + "keyid": "5fe7412b6449372a315109f192c89d9146bfc83cc7cf1b2bd0ba531222a1b062", + "sig": "0eUWs/MK7/vnOlAWEgUp5sUyfGyOWMoJVMBwO1gYsHS4dSrnnehCv018lUYM0lIa5A2QmrCZBiwoDDp9jwYtAQ==" + } + ] +} diff --git a/scripts/trust.mjs b/scripts/trust.mjs new file mode 100644 index 0000000..44893c6 --- /dev/null +++ b/scripts/trust.mjs @@ -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 +// node scripts/extensions/trust.mjs key +// node scripts/extensions/trust.mjs root --version N --root ... --root-threshold N \ +// --catalog ... --catalog-threshold N --sign ... +// node scripts/extensions/trust.mjs publisher --id ID --name NAME --key ... \ +// --namespace NS... [--version N] --sign ... +// node scripts/extensions/trust.mjs bundle ... +// node scripts/extensions/trust.mjs catalog --in --publisher ... \ +// --version N --expires --sign ... +// node scripts/extensions/trust.mjs release --sign +// +// A 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 '); + 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 '); + 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 '); + 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)); diff --git a/scripts/verify-catalog.mjs b/scripts/verify-catalog.mjs new file mode 100644 index 0000000..a4ed948 --- /dev/null +++ b/scripts/verify-catalog.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Checks a signed catalog the way a srelens host does before it trusts one +// (srelens/srelens#559), so CI never publishes a catalog hosts would refuse: +// +// - trust/root.json is signed by its own root role, at its threshold; +// - the catalog is signed by that root's catalog role, at its threshold; +// - every publisher delegation in it is signed by the catalog role too; +// - it has not expired, and its version is higher than `--previous`'s; +// - it carries exactly the entries catalog.json lists. +// +// A host checks all of this again, and more (crates/registry/src/extensions/trust.rs and +// catalog.rs in srelens/srelens). This is the part a mistake here could get wrong: the +// wrong key in the secret, a delegation signed by another key, a stale or rolled-back file. +// +// node scripts/verify-catalog.mjs catalog.signed.json [--previous ] +import { createHash, createPublicKey, verify } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; + +const TYPES = { + root: 'application/vnd.srelens.root+json', + catalog: 'application/vnd.srelens.catalog+json', + publisher: 'application/vnd.srelens.publisher+json', +}; +const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); + +function fail(message) { + console.error(`verify-catalog.mjs: ${message}`); + process.exit(1); +} + +function readJson(path, what) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + fail(`${what} (${path}) is not readable JSON: ${error.message}`); + } +} + +/** DSSE's pre-authentication encoding: what a signature covers. */ +function pae(type, body) { + return Buffer.concat([Buffer.from(`DSSEv1 ${Buffer.byteLength(type)} ${type} ${body.length} `), body]); +} + +/** A role's keys by ID, each ID checked to be the SHA-256 of its key, as the host computes it. */ +function roleKeys(root, role) { + return Object.fromEntries( + root.roles[role].keyids.map((id) => { + const spec = root.keys[id]; + if (!spec || spec.keytype !== 'ed25519') fail(`the ${role} role names key ${id}, which the root does not list`); + const raw = Buffer.from(spec.keyval.public, 'hex'); + if (createHash('sha256').update(raw).digest('hex') !== id) fail(`root key ${id} is listed under another key's ID`); + return [id, createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' })]; + }), + ); +} + +/** The payload of `envelope`, when at least `threshold` distinct keys of `keys` signed it. */ +function open(envelope, type, keys, threshold, what) { + if (envelope?.payloadType !== type) fail(`${what} is not a ${type} document`); + const body = Buffer.from(envelope.payload, 'base64'); + const message = pae(type, body); + const signed = new Set(); + for (const signature of envelope.signatures ?? []) { + const key = keys[signature.keyid]; + if (!key || signed.has(signature.keyid)) continue; + if (verify(null, message, key, Buffer.from(signature.sig, 'base64'))) signed.add(signature.keyid); + } + if (signed.size < threshold) fail(`${what} is signed by ${signed.size} of the ${threshold} keys it needs`); + return JSON.parse(body); +} + +const args = process.argv.slice(2); +const previousAt = args.indexOf('--previous'); +const previousPath = previousAt === -1 ? undefined : args.splice(previousAt, 2)[1]; +const [catalogPath = 'catalog.signed.json'] = args; + +const pinned = readJson(new URL('../trust/root.json', import.meta.url), 'trust/root.json'); +const claimed = JSON.parse(Buffer.from(pinned.payload ?? '', 'base64')); +const root = open(pinned, TYPES.root, roleKeys(claimed, 'root'), claimed.roles.root.threshold, 'trust/root.json'); +const catalogKeys = roleKeys(root, 'catalog'); +const threshold = root.roles.catalog.threshold; + +const catalog = open(readJson(catalogPath, 'the signed catalog'), TYPES.catalog, catalogKeys, threshold, 'the catalog'); +if (catalog._type !== 'catalog' || catalog.schemaVersion !== 2) fail('not a schema 2 catalog document'); +if (!Number.isSafeInteger(catalog.version) || catalog.version < 1) fail(`version ${catalog.version} is not a whole number of at least 1`); +const expires = Date.parse(catalog.expires); +if (Number.isNaN(expires)) fail(`expiry ${catalog.expires} is not an RFC 3339 time`); +if (expires <= Date.now()) fail(`the catalog expired on ${catalog.expires}`); + +const publishers = (catalog.publishers ?? []).map((delegation, index) => { + const publisher = open(delegation, TYPES.publisher, catalogKeys, threshold, `publisher delegation ${index}`); + if (publisher._type !== 'publisher' || !publisher.namespaces?.length) fail(`publisher delegation ${index} delegates nothing`); + return publisher; +}); + +const source = readJson('catalog.json', 'catalog.json'); +if (JSON.stringify(catalog.extensions) !== JSON.stringify(source.extensions)) { + fail('the signed catalog does not carry exactly the entries catalog.json lists'); +} + +if (previousPath && existsSync(previousPath)) { + const previous = JSON.parse(Buffer.from(readJson(previousPath, 'the previous signed catalog').payload, 'base64')); + // A host refuses a version lower than one it has verified, and a different catalog under + // the same version. + if (catalog.version <= previous.version) { + fail(`version ${catalog.version} is not higher than the published version ${previous.version}`); + } +} + +console.log( + `catalog version ${catalog.version}, expires ${catalog.expires}: ${catalog.extensions.length} apps; publishers ${publishers + .map((publisher) => `${publisher.id} (${publisher.namespaces.join(', ')})`) + .join('; ')}`, +); diff --git a/trust/root.json b/trust/root.json new file mode 100644 index 0000000..b8a60b9 --- /dev/null +++ b/trust/root.json @@ -0,0 +1,14 @@ +{ + "payloadType": "application/vnd.srelens.root+json", + "payload": "ewogICJfdHlwZSI6ICJyb290IiwKICAidmVyc2lvbiI6IDEsCiAgImtleXMiOiB7CiAgICAiODJlZjkwY2U1MTU2Njc1ODQ0ZTRhNzNmZGQ5ZDg2N2EyZWYzYThmMzcyODM2YWQxZTI4MWYwYzYyMzQzYTlhNCI6IHsKICAgICAgImtleXR5cGUiOiAiZWQyNTUxOSIsCiAgICAgICJzY2hlbWUiOiAiZWQyNTUxOSIsCiAgICAgICJrZXl2YWwiOiB7CiAgICAgICAgInB1YmxpYyI6ICI3MGRlM2U5YWU3NGUzNTcyYWJhYmMyYjAxY2RlOWI1MmE4OWY0ZmU4NzNkZjI3MjIzMzc3ZjYxZTg4MzZhYzJjIgogICAgICB9CiAgICB9LAogICAgIjE1MWExMzA2NjliYTZlMGZlOWFlMzliOTFlYzhlZmExMGRiMzMwNTI3ODM4ZDdlNTZjYjBjMGU3MmNmMjg3ZGIiOiB7CiAgICAgICJrZXl0eXBlIjogImVkMjU1MTkiLAogICAgICAic2NoZW1lIjogImVkMjU1MTkiLAogICAgICAia2V5dmFsIjogewogICAgICAgICJwdWJsaWMiOiAiYmU4NGE2NmFlOTE3NTZhNGZlOTc1ODUxNjJjNzIzNmM5OTNkM2Q2NWJiNjUzZWZlYjhlNGM0ZTY3ZGEzYWI4YyIKICAgICAgfQogICAgfSwKICAgICI1NmQzMWE0MWQ3NDdlNmNkODRkZTNkOGFlOGNmNTRlYWU2OGMxNjc3NDRiZTJmYzNmZDJlMzRkODVmN2E0Y2I2IjogewogICAgICAia2V5dHlwZSI6ICJlZDI1NTE5IiwKICAgICAgInNjaGVtZSI6ICJlZDI1NTE5IiwKICAgICAgImtleXZhbCI6IHsKICAgICAgICAicHVibGljIjogIjkwYjY5YjFkMzUyMGQ3ODI5ODI2OTBhNTJhYjg2NTRmYTYyZGIwZmM5NjE0MTZmOGQyYjc3YmE4ZDk5NTY0ZGUiCiAgICAgIH0KICAgIH0sCiAgICAiNWZlNzQxMmI2NDQ5MzcyYTMxNTEwOWYxOTJjODlkOTE0NmJmYzgzY2M3Y2YxYjJiZDBiYTUzMTIyMmExYjA2MiI6IHsKICAgICAgImtleXR5cGUiOiAiZWQyNTUxOSIsCiAgICAgICJzY2hlbWUiOiAiZWQyNTUxOSIsCiAgICAgICJrZXl2YWwiOiB7CiAgICAgICAgInB1YmxpYyI6ICIyZTAzOTFmNWU2YTNhZDgzZTc3NmE5MzRlNDcxYWMxMjg0Mjg0MWI4YjFjZWMwOWI1NmQzM2M2M2ZjYWQyZGVmIgogICAgICB9CiAgICB9CiAgfSwKICAicm9sZXMiOiB7CiAgICAicm9vdCI6IHsKICAgICAgImtleWlkcyI6IFsKICAgICAgICAiODJlZjkwY2U1MTU2Njc1ODQ0ZTRhNzNmZGQ5ZDg2N2EyZWYzYThmMzcyODM2YWQxZTI4MWYwYzYyMzQzYTlhNCIsCiAgICAgICAgIjE1MWExMzA2NjliYTZlMGZlOWFlMzliOTFlYzhlZmExMGRiMzMwNTI3ODM4ZDdlNTZjYjBjMGU3MmNmMjg3ZGIiLAogICAgICAgICI1NmQzMWE0MWQ3NDdlNmNkODRkZTNkOGFlOGNmNTRlYWU2OGMxNjc3NDRiZTJmYzNmZDJlMzRkODVmN2E0Y2I2IgogICAgICBdLAogICAgICAidGhyZXNob2xkIjogMgogICAgfSwKICAgICJjYXRhbG9nIjogewogICAgICAia2V5aWRzIjogWwogICAgICAgICI1ZmU3NDEyYjY0NDkzNzJhMzE1MTA5ZjE5MmM4OWQ5MTQ2YmZjODNjYzdjZjFiMmJkMGJhNTMxMjIyYTFiMDYyIgogICAgICBdLAogICAgICAidGhyZXNob2xkIjogMQogICAgfQogIH0KfQo=", + "signatures": [ + { + "keyid": "82ef90ce5156675844e4a73fdd9d867a2ef3a8f372836ad1e281f0c62343a9a4", + "sig": "U2eDOUtD1RpScPswG/Fuul3bhLv9l7zM7Mi8TqsEWluVUY5OtyulmGKbwjDRyWbqR/IxxP9/XWxP8TcmBSOYBQ==" + }, + { + "keyid": "151a130669ba6e0fe9ae39b91ec8efa10db330527838d7e56cb0c0e72cf287db", + "sig": "kQI/tqMDqGkii4PxYW4XEEPib5dnzdzgJDaJqVZagAvrnke4hmU2judSXXb1Sta4MGnG6BJK9Dj3LocU/QoqAA==" + } + ] +}