Content signing & provenance
How publishers sign lab-content bundles with ed25519, how to generate keys and sign and verify a bundle, and how an operator configures the trust store and enforcement (ADR-057).
Why bundles are signed
A lab-content bundle already carries a sha256 checksum over its content bytes, which catches corruption and truncation in transit. A checksum proves integrity, but not provenance: anyone can recompute it over modified content. Publisher signing adds authenticity - proof that a bundle came from a specific publisher and has not been changed since (ADR-057).
Signing is ed25519 and asymmetric, deliberately unlike the platform's internal HMAC intent key. Publishers hold private keys; the server and the seeder hold only public keys. So the worst a compromised server can do is fail to accept a bundle - it can never forge one. The signature covers the exact canonical content bytes the checksum covers, and the private key lives only in the authoring CLI.
Generating a publisher key
A publisher - Invictus, or the partner CyberLab - needs one ed25519 keypair. Generate it with the authoring tool:
- academy-bundle keygen <key-id> writes <key-id>.key (the private key: keep it secret, never commit it) and <key-id>.pub (the public key, safe to share).
- The key id names the key, for example invictus or cyberlab. It is embedded in every bundle the key signs, so a verifier knows which public key to check against.
- A leaked private key is universal forgery, so it has no default and is never shipped. Rotating a key is publishing a new keypair under a new id and then revoking the old one, with both active during the overlap. Retiring a key is academy-bundle trust revoke, which takes it out of the active set from the next import or seed onwards.
Signing and verifying a bundle
Signing is opt-in on the build step: set the key file and id in the environment and build as usual. Verification is available offline, with no database, against a directory of trusted public keys.
- BUNDLE_SIGNING_KEY_FILE=<key-id>.key BUNDLE_SIGNING_KEY_ID=<key-id> academy-bundle build <content-file> produces a signed bundle; without those variables the build is an unsigned, checksum-only bundle exactly as before.
- PUBLISHER_TRUST_DIR=<dir> academy-bundle verify <bundle> re-checks the checksum and the publisher signature against the public keys in <dir>, and prints the verified publisher.
Configuring the trust store
The server and the seeder resolve which publishers to trust from the platform.publisher_key table, and they resolve it per import and per seed rather than once at start-up. Revoking a key therefore takes effect on the very next import with no restart. A bundle's embedded key id resolves to the matching active public key.
Manage the set with academy-bundle trust: import-dir bootstraps it from an existing directory of .pub files, add registers one key, revoke retires one, reactivate re-enables a revoked key deliberately, list shows the set, and reverify reports which published global content was signed by a key that is now revoked or absent. A re-run of the bootstrap never silently un-revokes a revoked key.
An empty trust set trusts no publisher: bundles still import and seed, but none is marked as from a verified publisher. If the trust set cannot be read at all, an import is refused rather than admitted unverified. PUBLISHER_TRUST_DIR now only feeds the offline verify command and the one-time bootstrap.
Keeping identifiers out of content
academy-bundle lint scans every string field of a content bundle for residual identifiers that could tie training material to a real organisation or person: email and UPN addresses, publicly routable IPv4 addresses, and real *.onmicrosoft.com tenant domains. The canonical synthetic conventions pass automatically (example.*, contoso.*, fabrikam.*, and the private, loopback, link-local, documentation, CGNAT and reserved IP ranges); anything else must be acknowledged in content/.pii-allowlist, which is the reviewer's sign-off record.
It runs in the pre-commit hook and in the content-integrity GitHub Actions workflow alongside the structural check, and a golden test keeps the allowlist in step with the shipped content. It is deliberately high-precision rather than exhaustive: GUIDs and ordinary hostnames are not flagged, because in cloud labs they are pervasive and legitimate.
This is a mechanical safety net, not a certification of anonymisation. A clean lint is necessary, never sufficient; the legally reviewed clean-room process is what actually anonymises source material.
Two lanes: enforcement and provenance
Signing is enforced where it has teeth - the global publisher lane - and is provenance-only on the open per-tenant lane, because any organisation admin may author and import their own content.
- Global seed lane (academy-bundle seed, how Invictus and CyberLab publish global content): with PUBLISHER_SIGNING_REQUIRED set, the seeder refuses any global content not signed by a trusted publisher. This is the production posture for the shared catalogue every tenant sees.
- Per-tenant import (POST /labs/import): a signature is never required, so open authoring stands. A valid trusted signature records the verified publisher on the import; an unsigned or unknown-publisher bundle imports with no claim.
- Both lanes always reject a signature that is present under a trusted key but does not verify: that is tampering, not open authoring.
What signing proves, and what it does not
A signature proves authenticity and integrity - these bytes, from this publisher, unmodified. It does not prove the content is safe to run. Authored lab content, including its Terraform and Dockerfiles, remains an execution surface, and it is the sandbox that contains it (ADR-015), not the signature.
This realises the signed-provenance intent of ADR-017 without its OCI-registry machinery, and delivers the publisher signing that ADR-045 and ADR-046 deferred. See ADR-057 for the full decision.