| layout | default | |||||
|---|---|---|---|---|---|---|
| title | Module Signing and Key Rotation | |||||
| permalink | /authoring/module-signing/ | |||||
| redirect_from |
|
|||||
| description | Runbook for signing bundled modules, placing public keys, rotating keys, and revoking compromised keys. | |||||
| keywords |
|
|||||
| audience |
|
|||||
| expertise_level |
|
This runbook defines the repeatable process for signing bundled modules and verifying signatures in SpecFact CLI.
Repository/public key path used by CLI verification:
resources/keys/module-signing-public.pem(repository source path)
Runtime key resolution order:
- Explicit key argument (internal verifier calls)
SPECFACT_MODULE_PUBLIC_KEY_PEM- Bundled key file at
resources/keys/module-signing-public.pem(source) orspecfact_cli/resources/keys/module-signing-public.pem(installed package)
Never store private signing keys in the repository.
Ed25519 (recommended):
openssl genpkey -algorithm ED25519 -out module-signing-private.pem
openssl pkey -in module-signing-private.pem -pubout -out module-signing-public.pemRSA 4096 (supported):
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out module-signing-private.pem
openssl pkey -in module-signing-private.pem -pubout -out module-signing-public.pemPreferred (strict, with private key):
- Key file:
--key-file <path>or setSPECFACT_MODULE_PRIVATE_SIGN_KEY_FILE(or legacySPECFACT_MODULE_SIGNING_PRIVATE_KEY_FILE). - Inline PEM: Set
SPECFACT_MODULE_PRIVATE_SIGN_KEY(or legacySPECFACT_MODULE_SIGNING_PRIVATE_KEY_PEM) to the PEM string; no file needed. Useful in CI where the key is in a secret. - Payload mode: Use
--payload-from-filesystemso the payload matches verify and publish tarball excludes (.git,tests, cache dirs).
KEY_FILE="${SPECFACT_MODULE_PRIVATE_SIGN_KEY_FILE:-.specfact/sign-keys/module-signing-private.pem}"
python scripts/sign-modules.py --key-file "$KEY_FILE" --payload-from-filesystem packages/*/module-package.yamlEncrypted private key options:
# Prompt interactively for passphrase (TTY)
python scripts/sign-modules.py --key-file "$KEY_FILE" --payload-from-filesystem packages/specfact-backlog/module-package.yaml
# Explicit passphrase flag (avoid shell history when possible)
python scripts/sign-modules.py --key-file "$KEY_FILE" --payload-from-filesystem --passphrase '***' packages/specfact-backlog/module-package.yaml
# Passphrase over stdin (CI-safe pattern)
printf '%s' "$SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" | \
python scripts/sign-modules.py --key-file "$KEY_FILE" --payload-from-filesystem --passphrase-stdin packages/specfact-backlog/module-package.yamlVersioning guard:
- The signer enforces module version increments for changed module contents.
- If module files changed and version is unchanged, signing fails until version is bumped.
- Override exists for exceptional local workflows:
--allow-same-version(not recommended). - Module versions are independent from CLI package version; bump only modules whose payload changed.
Changed-modules automation (recommended for release prep):
# Bump changed modules by patch and sign only those modules
hatch run python scripts/sign-modules.py \
--key-file "$KEY_FILE" \
--payload-from-filesystem \
--changed-only \
--base-ref origin/dev \
--bump-version patch
# Verify after signing (must match sign payload mode). This matches dev-targeting CI: checksum +
# version policy only—dev CI omits --require-signature:
hatch run python scripts/verify-modules-signature.py --payload-from-filesystem --enforce-version-bump --version-check-base origin/dev
# Main-equivalent (strict) verification: dev CI does not run this, but use it locally when you want
# cryptographic signatures enforced like on main. Same verifier flags as above, plus
# --require-signature. Example with --version-check-base origin/dev (typical feature → dev PR);
# before merging to main, point --version-check-base at origin/main so version policy matches the
# integration target:
hatch run python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump --version-check-base origin/dev
hatch run python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump --version-check-base origin/mainWrapper for single manifest:
bash scripts/sign-module.sh --key-file "$KEY_FILE" packages/specfact-backlog/module-package.yaml
# stdin passphrase:
printf '%s' "$SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" | \
bash scripts/sign-module.sh --key-file "$KEY_FILE" --passphrase-stdin packages/specfact-backlog/module-package.yamlLocal test-only unsigned mode:
python scripts/sign-modules.py --allow-unsigned --payload-from-filesystem packages/specfact-backlog/module-package.yamlChecksum + version enforcement (matches dev / feature CI and pre-commit when not on main):
python scripts/verify-modules-signature.py --payload-from-filesystem --enforce-version-bumpStrict verification (checksum + signature required, matches main CI):
python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bumpWith explicit public key file:
python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump --public-key-file resources/keys/module-signing-public.pempr-orchestrator.yml job verify-module-signatures always runs with --payload-from-filesystem --enforce-version-bump. It adds --require-signature only when the pull request or push targets main. For dev and feature work, the job still enforces checksums and version bumps so unsigned manifests can land on dev; signatures are expected by the time changes reach main.
Workflow sign-modules-on-approval.yml runs when a review is submitted and approved on a PR whose base is dev or main, and only when the PR head is in this repository (head.repo equals the base repo). It checks out github.event.pull_request.head.sha (the commit that was approved, not the moving branch tip), uses SPECFACT_MODULE_PRIVATE_SIGN_KEY and SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE (each validated with a named error if missing), discovers changes against the merge-base with the base branch (not the moving base tip alone), runs scripts/sign-modules.py --changed-only --bump-version patch --payload-from-filesystem, and commits results without [skip ci] so PR checks and downstream workflows run on the signed head. If git push is rejected because the PR branch advanced after approval, the job fails with guidance to update the branch and re-approve. Fork PRs are skipped (the default GITHUB_TOKEN cannot push to a contributor fork).
The first pre-commit hook runs scripts/pre-commit-verify-modules-signature.sh, which mirrors CI: --require-signature on branch main, or when GITHUB_BASE_REF=main in Actions pull-request contexts; otherwise the same baseline formal verify as PRs to dev (--payload-from-filesystem --enforce-version-bump, no --require-signature). On failure it runs sign-modules.py --allow-unsigned --payload-from-filesystem (--changed-only vs HEAD, then vs HEAD~1 for manifests still failing), git add those module-package.yaml paths, and re-verifies. It does not rewrite registry/ (publish workflows own signed artifacts and index updates). yaml-lint allows a semver ahead manifest vs registry/index.json until publish-modules reconciles.
- Generate new keypair in secure environment.
- Replace
resources/keys/module-signing-public.pemwith new public key. - Re-sign all bundled module manifests with the new private key.
- Run verifier locally:
python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem. - Commit public key + re-signed manifests in one change.
- Merge to
dev, thenmainafter CI passes.
If a private key is compromised:
- Treat all signatures from that key as untrusted.
- Generate new keypair immediately.
- Replace public key file in repo.
- Re-sign all bundled modules with new private key.
- Merge emergency fix branch and invalidate prior release artifacts operationally.
Current limitation:
- Runtime key-revocation list support is not yet implemented.
- Revocation is currently handled by rotating the trusted public key and re-signing all bundled manifests.