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==" + } + ] +}