Appearance
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:
- 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.
- 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. - 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:
- The interceptor walks
peer.FromContext(ctx), extracts thecredentials.TLSInfo, and pulls the firstspiffe://URI SAN from the client certificate. - It looks up the extracted SPIFFE ID in the operator-supplied allow-list (
--allow-spiffe-idon the signer CLI, repeatable). The allow-list is held as amap[string]struct{}for O(1) admission. - One
audit.Entryis written for each outcome — granted, denied-because-not-on-list, or denied-because-no-client-cert — under the fixedSubject = "service:signer:peer=<spiffe-id>"andRelation = "connect". The entry is emitted before method dispatch; the handler never sees a denied call. - A denial returns
codes.PermissionDeniedwith the stable status-detail string"signing: client identity denied". The auditReasonon denials is pinned toinsufficient_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.
| Field | Value |
|---|---|
Subject | service:signer (fixed; peer identity is on the interceptor's separate admission entry under the service:signer:peer=… subject) |
Relation | sign for Sign RPCs; public_key for PublicKey RPCs; rotate for rotation control-plane calls written by RotationService |
Object | signing-key:<scope>:<key_id> — a single object LIKE 'signing-key:%' filter catches every entry this bounded context emits |
Reason | granted on success; insufficient_relation on ErrClientIdentityDenied, ErrKeyNotFound, ErrScopeMismatch, ErrInvariant, or any unclassified error; caveat_violation on ErrKeyRetired, ErrRotationInProgress, or ErrProviderUnavailable (state-time lifecycle refusals) |
CaveatContext | Always the empty slice. The signer middleware never inspects caveats; the []string field structurally enforces the names-only contract the audit substrate pins |
Timestamp | The 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.