decision
ADR-0038: Plugin release-artifact signing via Cloud KMS asymmetric key
ADR-0038 (Accepted, 2026-07-23): Plugin release-artifact signing via Cloud KMS asymmetric key.
Status: Accepted
Date: 2026-07-23
Deciders: Seth; cutover (infra); ai-engineer persona review (docs/superpowers/reviews/2026-07-22-ai-engineer-security-plugin-signing.md)
Context
The plugin release/auto-update system (memberintel#576, spec docs/superpowers/specs/2026-07-21-plugin-release-update-and-anti-abuse-design.md) ships a memberintel-connect zip that WordPress sites running the plugin auto-update to and execute. That is a supply-chain path directly into customer sites: an attacker who could substitute the served bytes would get code execution on every updating install. The zip’s authenticity therefore has to be cryptographically verifiable by the plugin against a key the plugin trusts, with no weaker fallback that an attacker could downgrade to.
Forces: (1) the signing private key must not be exfiltratable (a leaked key = forge any release); (2) the plugin verifies in PHP, so verification must use stock openssl with no extra PHP dependencies; (3) key rotation / break-glass must be operable; (4) this is distinct from — and must not be conflated with — the backend’s separate need to hand the plugin a time-limited download URL for a private bucket object.
Decision
- Sign with a Cloud KMS asymmetric key, one per environment (
mi-plugin-zip-signing-<env>in keyringmi-plugin-release-<env>), algorithmRSA_SIGN_PKCS1_2048_SHA256. The private key is never-extractable — signing is only everkms.asymmetricSignby the release CI’s SA; no raw private key exists in Secret Manager or anywhere else. - PKCS#1 v1.5 (not PSS) so the plugin verifies with a plain
openssl_verify($data, $sig, $pubkey, OPENSSL_ALGO_SHA256)— no extra PHP deps. - The versioned CryptoKey IS the plugin’s pinned-pubkey SET (condition C3): the plugin bundles the public key(s); introducing a new key version = rolling in a new pinned key, disabling a version = break-glass revocation. Pull pubkeys via
gcloud kms keys versions get-public-key, never trust a pasted PEM. - Storage/format: the signature is stored standard-base64 in
plugin_releases.signature; the SHA-256 hexchecksumis a transport/integrity aid only. There is no checksum-only fallback — the plugin verifies the signature, full stop. - Distinct capability — download URL:
GET /api/v1/plugin/downloadmints a short-lived V4 signed GCS URL using the Cloud Run runtime SA’ssignBlobon the private bucket. That is object-access authorization, NOT artifact authenticity, and uses a different mechanism (runtime SA, not the KMS signing key). Do not conflate the two.
Consequences
Positive:
- Private signing key is non-exfiltratable (KMS-resident); a compromised CI host can request signatures but never obtains the key.
- Plugin-side verification is dependency-free stock PHP
openssl. - Rotation/revocation is a KMS key-version operation, no plugin redeploy needed to introduce a key (the plugin pins the set).
Negative / costs:
- Publishing depends on KMS availability + the CI SA’s
asymmetricSignIAM. - Verification integrity depends on the plugin pinning the correct pubkey set (Task 1.4, not yet shipped) — until then the served signature is unverified client-side.
Mitigations:
- Backend publish-time guards (existence + size + md5 vs the actual GCS object) catch a metadata/artifact mismatch before the immutable row is written (see runbook 18 §6, and the reproducible-build requirement, #638), so a bad release fails safe rather than silently.
- Per-env keys mean a staging compromise can’t forge a prod release.
Alternatives considered
- Raw RSA keypair with the private key in Secret Manager — rejected: the key is then extractable (a Secret Manager read = key exfiltration); KMS
asymmetricSignkeeps it non-extractable. (This ADR corrects a loose “Secret Manager private key” phrasing in an early coordination message; the ratified design was always KMS.) - Checksum-only (SHA-256) with no signature — rejected: a checksum proves integrity in transit but not authenticity; anyone who can substitute the object can substitute its checksum. No authenticity guarantee against a bucket/serving compromise.
- RSASSA-PSS — rejected: no security downside, but PKCS#1 v1.5 keeps the plugin’s PHP verification to a single stock
openssl_verifycall with no PSS-specific handling.