Skip to content

Security invariants

This page covers the three load-bearing security contracts the signer relies on. For the RPC and operator-facing surface that sits on top see gRPC surface and operations; for the entry point and ubiquitous language see the index.

Signing material never leaks through core-binary memory

The plexsphere core binary (cmd/plexsphere) never holds an Ed25519 private half in its address space. Every signature is produced by calling Sign on the plexsphere-signer process over mTLS gRPC; the core binary caches only public halves. The invariant rests on three reasons, listed roughly in order of the attacker effort each raises:

  1. Process boundary. The signer runs as a separate Deployment with its own ServiceAccount, its own NetworkPolicy, and its own KMS / HSM handle. A memory-disclosure vulnerability in the core binary (Heartbleed-class read, panic-triggered stack dump, debug endpoint) cannot exfiltrate signing material because the material is not mapped into the core binary's process.
  2. Memory disclosure. Even a well-formed core-binary bug that leaks its own heap — a template-injection printf, a goroutine dump on panic, a misconfigured pprof endpoint — discloses only public key bytes. The private half stays behind the KMS / HSM boundary the signer speaks to.
  3. Sharpest privilege edge. Keeping the signing private keys reachable only from a dedicated process (with its own service account, NetworkPolicy, and minimal attack surface) concentrates the privilege the way an operator reasons about it — see README § Runtime Topology for the top-level framing.

The invariant is enforced by static analysis in tests/integration/signer_core_binary_no_material_test.go: the test builds the full transitive import graph of cmd/plexsphere via go list -deps, filters to first-party packages, and scans each non-test source file for any occurrence of ed25519.PrivateKey. A single match is a hard failure. Breaking the invariant is therefore a compile-time-visible regression, not a runtime-observable one.

mTLS peer-identity enforcement

The signer's gRPC surface is mTLS-only. Every RPC runs through the unary and stream interceptors in internal/signing/peeridentity/ BEFORE the method handler dispatches:

  1. The interceptor walks peer.FromContext(ctx), extracts the credentials.TLSInfo, and pulls the first spiffe:// URI SAN from the client certificate.
  2. It looks up the extracted SPIFFE ID in the operator-supplied allow-list (--allow-spiffe-id on the signer CLI, repeatable). The allow-list is held as a map[string]struct{} for O(1) admission.
  3. One audit.Entry is written for each outcome — granted, denied-because-not-on-list, or denied-because-no-client-cert — under the fixed Subject = "service:signer:peer=<spiffe-id>" and Relation = "connect". The entry is emitted before method dispatch; the handler never sees a denied call.
  4. A denial returns codes.PermissionDenied with the stable status-detail string "signing: client identity denied". The audit Reason on denials is pinned to insufficient_relation — operator dashboards and SIEM rules filter on this exact value.

An empty allow-list is the fail-closed default: the interceptor rejects every caller. Wiring the signer without a populated allow-list is an explicit safety configuration. gRPC reflection is intentionally NOT registered on the server so a compromised peer cannot enumerate RPCs without hitting a business-logic handler — the wire schema is discovered exclusively via the committed .proto file.

Audit contract

Every signer RPC produces exactly one audit.Entry via internal/signing/audit_middleware.go — regardless of outcome. Grants are as interesting as denials for forensic replay, so the middleware emits on success too.

FieldValue
Subjectservice:signer (fixed; peer identity is on the interceptor's separate admission entry under the service:signer:peer=… subject)
Relationsign for Sign RPCs; public_key for PublicKey RPCs; rotate for rotation control-plane calls written by RotationService
Objectsigning-key:<scope>:<key_id> — a single object LIKE 'signing-key:%' filter catches every entry this bounded context emits
Reasongranted on success; insufficient_relation on ErrClientIdentityDenied, ErrKeyNotFound, ErrScopeMismatch, ErrInvariant, or any unclassified error; caveat_violation on ErrKeyRetired, ErrRotationInProgress, or ErrProviderUnavailable (state-time lifecycle refusals)
CaveatContextAlways the empty slice. The signer middleware never inspects caveats; the []string field structurally enforces the names-only contract the audit substrate pins
TimestampThe injected clock() at admission time, always UTC

The shape is pinned by the integration contract test at tests/integration/signer_audit_contract_test.go, which exercises granted Sign, caveat-violation Sign (retired key), granted PublicKey, and rotation Open against a real Postgres testcontainer. One mutation → one audit entry is an invariant, not a best-effort target.