Module Signing and Key Rotation
This runbook defines the repeatable process for signing bundled modules and verifying signatures in SpecFact CLI.
Key Placement
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.
Generate Keys
Ed25519 (recommended):
openssl genpkey -algorithm ED25519 -out module-signing-private.pem
openssl pkey -in module-signing-private.pem -pubout -out module-signing-public.pem
RSA 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.pem
Sign Bundled Modules
Preferred (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.
KEY_FILE="${SPECFACT_MODULE_PRIVATE_SIGN_KEY_FILE:-.specfact/sign-keys/module-signing-private.pem}"
python scripts/sign-modules.py --key-file "$KEY_FILE" src/specfact_cli/modules/*/module-package.yaml
python scripts/sign-modules.py --key-file "$KEY_FILE" modules/*/module-package.yaml
Encrypted private key options:
# Prompt interactively for passphrase (TTY)
python scripts/sign-modules.py --key-file "$KEY_FILE" modules/backlog-core/module-package.yaml
# Explicit passphrase flag (avoid shell history when possible)
python scripts/sign-modules.py --key-file "$KEY_FILE" --passphrase '***' modules/backlog-core/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" --passphrase-stdin modules/backlog-core/module-package.yaml
Versioning 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" \
--changed-only \
--base-ref origin/dev \
--bump-version patch
# Verify after signing
hatch run python scripts/verify-modules-signature.py --require-signature --enforce-version-bump --version-check-base origin/dev
Wrapper for single manifest:
bash scripts/sign-module.sh --key-file "$KEY_FILE" modules/backlog-core/module-package.yaml
# stdin passphrase:
printf '%s' "$SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" | \
bash scripts/sign-module.sh --key-file "$KEY_FILE" --passphrase-stdin modules/backlog-core/module-package.yaml
Local test-only unsigned mode:
python scripts/sign-modules.py --allow-unsigned modules/backlog-core/module-package.yaml
Verify Signatures Locally
Strict verification (checksum + signature required):
python scripts/verify-modules-signature.py --require-signature
With explicit public key file:
python scripts/verify-modules-signature.py --require-signature --public-key-file resources/keys/module-signing-public.pem
CI Enforcement
pr-orchestrator.yml contains a strict gate:
- Job:
verify-module-signatures - Command:
python scripts/verify-modules-signature.py --require-signature
This runs on PR/push for dev and main and fails the pipeline if module signatures/checksums are missing or stale.
Rotation Procedure
- 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. - Commit public key + re-signed manifests in one change.
- Merge to
dev, thenmainafter CI passes.
Revocation Procedure
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.