M MemberIntel KB
Activity Decisions

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

  1. Sign with a Cloud KMS asymmetric key, one per environment (mi-plugin-zip-signing-<env> in keyring mi-plugin-release-<env>), algorithm RSA_SIGN_PKCS1_2048_SHA256. The private key is never-extractable — signing is only ever kms.asymmetricSign by the release CI’s SA; no raw private key exists in Secret Manager or anywhere else.
  2. 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.
  3. 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.
  4. Storage/format: the signature is stored standard-base64 in plugin_releases.signature; the SHA-256 hex checksum is a transport/integrity aid only. There is no checksum-only fallback — the plugin verifies the signature, full stop.
  5. Distinct capability — download URL: GET /api/v1/plugin/download mints a short-lived V4 signed GCS URL using the Cloud Run runtime SA’s signBlob on 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 asymmetricSign IAM.
  • 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 asymmetricSign keeps 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_verify call with no PSS-specific handling.
For: S Seth Shoultes A AI Engineer B Blair Williams S Santiago Perez Asis P Product Lead