Key custody: where every private key is generated and where it lives
Whose process created the private key, and whose disk holds it, is the question
an auditor asks first and the one a whitepaper is worst at answering. This page
answers it per credential kind, in one table, and a CI guard
(docs/custody_test.go) keeps the table matched to the code.
Read it as a map of the issuance boundary. A requester-supplied CSR or public key does not send its private key to the control plane. That observation cannot prove where the key was originally generated or whether a previous operation handled it. Generated by the control plane means this issuance did handle it, and says so.
Mechanism
A private key can be created in exactly three places in a trstctl deployment:
- The requester — your host, your workload, your device. A PKCS#10 request travels up, a certificate travels down, and the control plane signs something it cannot read a key from. This is the target shape for every credential kind.
- The isolated signer — CA keys only. They are created inside the signer process (AN-4), never leave it, and are never subject keys.
- The control plane — the remaining legacy paths, each named below with what replaces it.
Per certificate and per kind
The table below answers this per credential KIND, which is the right level for a design review and the wrong level for an audit. An auditor is not asking about ACME in general — they are asking about the certificate in front of them, and a kind-level table cannot say whether that one took the modern path or the deprecated server-keygen path retained beside it.
So every certificate now carries its own custody record (epic B5), written from what the issuing code actually did: origin, storage class, exportability, and the actor where one is known. The certificate detail page shows it, and the served API carries it on every certificate response.
For host-agent renewal, issuance initially records the facts known at CSR signing:
the key originated on the authenticated host agent and which agent generated it.
The control plane does not guess the final storage location before installation.
After the connector installs the certificate, the agent's v2 terminal receipt
signs the certificate fingerprint, origin, storage class, exportability, and
generator with the same key behind its mTLS certificate. Missing, partial,
connector-inconsistent, wrong-attempt, or altered custody is refused; the job is
not completed. The verified receipt appends certificate.custody.attested, and
that event projects the final four fields so replay reconstructs the same inventory.
Where a host agent's private-key inventory reports a key file whose public key
matches a certificate, the certificate detail lists it as Matching key seen on
hosts (key_observations: the agent, the path, the file mode, whether the file
is encrypted, its format, and when it was last reported). That is an observation
of where a matching key sits, taken from the agent's metadata-only report and named
by the agent's own certificate; it is never a custody record, so it does not
change the custody fields or their summary. Only a certificate whose bytes trstctl
holds (issued, adopted from a host, or imported) has a public key to compare.
Empty means unrecorded, and that is a third answer rather than a default. A certificate a network scan found has an origin nobody observed, and the console says so in words — "unrecorded is not the same as safe" — rather than letting a blank field read as reassurance. Custody written at issuance is never overwritten by a later discovery upsert, so a scan re-finding a certificate cannot quietly turn an audit fact back into an unknown.
Framework evidence packs aggregate those tenant-scoped rows. Their signed
manifest contains counts by the closed custody vocabulary and explicitly lists
every incomplete certificate plus the fields it lacks. The Policy console renders
the same gap list. A legacy row with only key_origin is incomplete evidence, not
silently counted as a complete custody record.
The table
| Credential kind | Key source for this issuance | Key stored where | Does this issuance handle the private key in the control plane? |
|---|---|---|---|
| ACME leaf (RFC 8555) | The ACME client | Wherever the client put it | No |
| EST leaf (RFC 7030) | The EST client | Wherever the client put it | No |
| SCEP leaf (RFC 8894) | The SCEP client / MDM | The device | No |
| CMP leaf (RFC 4210) | The CMP client | Wherever the client put it | No |
Identity leaf, subject_csr_pem supplied |
The requester | Wherever the requester put it | No |
| Identity leaf, no CSR, agent-executed target | The host agent | Installed into the target's connector-specific host store; transient copies stay in locked host memory | No — only the CSR travels up |
| Identity leaf, no CSR, retained control-plane target | The control plane | Tenant-bound encrypted recovery record; sealed deploy intent when a control-plane connector is configured | Yes — deprecated, see below |
| CA root / intermediate | The isolated signer | Inside the signer's key store or an HSM | No — it never leaves the signer |
| Agent enrollment identity | The agent | The agent's own key store | No |
| SSH host / user certificate | The requesting host or user | Wherever the requester put it | No |
| SPIFFE X.509-SVID, host-agent Workload API | The host agent | Transient locked memory in the agent, then returned to the workload over that host's UDS | No — only the public key travels up |
| SPIFFE X.509-SVID, control-plane compatibility Workload API | The control plane | Transient locked memory, then returned over the control-plane-local UDS | Yes — deprecated compatibility path, see below |
PKI-as-a-secret, csr_pem supplied |
The requester | Wherever the requester generated it | No |
PKI-as-a-secret, common_name supplied |
The control plane | Returned once in the response, not persisted | Yes — deprecated, see below |
| Automated renewal successor, agent-executed target | The host agent | Installed into the target's connector-specific host store; transient copies stay in locked host memory | No — only the CSR travels up |
Automated renewal successor, recorded subject_csr_pem |
The original requester | Wherever the requester kept that matching key; renewal creates no new subject key | No |
| Automated renewal successor, no CSR and retained control-plane target | The control plane | Tenant-bound encrypted recovery record; sealed deploy intent when a control-plane connector is configured | Yes — deprecated identity fallback, see below |
| Ephemeral workload credential (attested) | The attested workload | Wherever the workload put it | No — it presents its own public key |
| Direct attested X.509-SVID | The requesting workload | Not established by the attestation request | No — only the public key travels up |
| Kubernetes TLS auto-issuance, source bound to a cluster agent | The agent running in the cluster | The TLS Secret the Ingress or Gateway names (key_storage=kubernetes_secret) |
No — only the CSR leaves the cluster, see below |
| Kubernetes TLS auto-issuance, no cluster agent bound | Nobody: nothing is delivered | No key or certificate is made; each resource is reported not delivered with the reason | No, see below |
| PQC migration reissue, older run | The control plane | Held in locked memory and wiped; nothing delivers it to the asset | Yes — only runs queued before host-csr migration, see below |
ACME, EST simple enrollment, SCEP, CMP, direct attested issuance, and a
certificate ordered through an external CA (POST /api/v1/external-cas/{id}/issue
takes only the caller's CSR; A121) record key_origin=requester through
certificate.recorded. They leave storage,
exportability, and the named key generator unrecorded: receiving a CSR or valid
workload proof does not establish those facts. The detail describes this issuance;
it does not attest the key's entire history. EST server key generation uses a
separate interface and does not acquire requester provenance from this rule.
A certificate issued before its issuing path recorded custody gets the origin
its own retained issuance event proves, and nothing else. The projection derives
it from the event alone (so a rebuild reproduces it) and marks it derived: the
certificate carries key_origin_derivation (the rule) and
key_origin_proof_event_id (the event an auditor can read), the summary says the
origin was derived and that the key's storage was not recorded, and the
certificate detail names the rule. Each rule is one issuing path whose retained
event shape, by itself, proves the origin, and it applies only to events older
than the moment that path began recording custody itself:
| Rule | Proves | From events of |
|---|---|---|
protocol_enrollment |
requester | ACME, EST, SCEP and CMP enrollments (each signs the client's CSR) |
external_ca_order |
requester | external CA orders taken through the CSR-only route |
ephemeral_credential, attested_svid, agent_broker |
requester | attested workload and brokered agent issuance (the caller presents its public key) |
identity_issuance |
control plane | identity issuance before CSR-first issuance existed |
identity_renewal |
control plane | renewals before CSR-preserving renewal existed |
kubernetes_auto_issuance |
control plane | Kubernetes TLS auto-issuance by earlier versions, which generated the key in the control plane |
pqc_migration_reissue |
control plane | the older PQC migration reissue, which built its CSR from a key it generated |
Everything else stays unrecorded: PKI-secret leaves, managed-hierarchy leaves, edge delegations, identity issuance from the window when a CSR could be honored but custody was not yet recorded, and the oldest rows that carry no issuance key. A recorded origin always replaces a derived one; a derived one never replaces a recorded one. On upgrade, rows projected by an older binary are derived once by an atomic rebuild. A derived control-plane origin lets an identity renew through the control-plane path while the tenant allows it; a derived requester origin still renews only through its requester's protocol, because no retained CSR links it to the renewal.
The four retained control-plane generators, and what replaces each
There are five Yes rows because the identity generator is reused by its
automated-renewal fallback. There are four distinct control-plane subject-key
generators: the three deprecated paths below and the reissue step of a PQC migration
run that predates host-csr migration. The tenant's switch governs all four.
Kubernetes TLS auto-issuance is not one of them: the agent running in the cluster
generates its keys (below).
Identity leaf without a CSR, including its renewal fallback. Supplying
subject_csr_pem is the primary direct-identity path: trstctl signs the request
you built and generates nothing. On renewal, the recorded CSR is honored again,
so the requester keeps the same key and the control plane still generates nothing.
If the deployment target says executor=agent, first issuance and renewal take a
different branch before any mint. The control plane queues endpoint.renew; the
host agent generates the key, sends only a CSR upward, receives the certificate,
installs the pair, verifies the live endpoint, and signs the custody receipt. This
is the shipped B2 path, not future work.
Before requesting a signature, the host agent seals the CSR and private key in
its tenant-bound, 0600 host rollback directory. If the job is claimed again
after a lost reply or agent restart, it reuses that exact CSR and key; the
signer's slot-zero, job-scoped idempotency key returns the same signed leaf
even when the claim attempt changes. A different CSR for that durable job is
refused, including when an older issuer recorded an attempt-scoped key. The agent
removes the pending copy only after the control plane accepts its successful
terminal report. If the acceptance reply is lost, the agent first replays its
encrypted original report after restart, clears only that job's pending key
slot, and then acknowledges the local report before claiming another job.
Keep --host-rollback-dir on durable local storage: losing or
corrupting that state makes the agent refuse recovery rather than silently
generating another subject key for the same job.
The retained fallback applies only when there is no CSR and the target is not
agent-executed. It calls the same control-plane key generator for first issuance
and renewal, and records issuance.server_side_keygen. The key is held in locked
memory. Before signing, a tenant-bound encrypted recovery record retains the key
and CSR so a retry uses the same subject. Inventory records
key_origin=control_plane, key_storage=sealed_store, and
key_exportable=exportable. These describe the encrypted recovery material;
they do not assert hardware protection. Older records with missing exportability
remain unrecorded because no new issuance evidence establishes their history. If a
control-plane connector is configured, the bytes are also sealed into its outbox
deploy intent; otherwise no deploy material is emitted. Working copies are wiped
after the dispatcher finishes. This compatibility path is deprecated, and
the event identifies the flows that must move to subject_csr_pem or an
agent-executed target.
Per-tenant switch. Each tenant decides whether this fallback may run at all:
GET/PUT /api/v1/tenant/key-generation (access:read/access:write),
trstctl tenant key-generation show|set, or Platform → Configuration →
Control-plane key generation. A tenant registered after the switch starts with
it off; the evaluation preset starts with it on and records why; a tenant that
predates the switch is decided once, on the leader's first start of a version that
has it, from its own use: it stays on if any of its certificates' custody (recorded,
or derived from the retained issuance event) says the control plane generated the
key, if any certificate from a path that could have generated one still has an
unrecorded origin, if it has a retained issuance.server_side_keygen record or an
X.509-SVID from the compatibility socket, or if that socket serves it; otherwise it
is turned off. The decision keeps its evidence, shown beside it, and never replaces
an operator's own decision; until it is made, the tenant keeps the behavior it had.
Every change names who made it and why in a tenant.control_plane_keygen.set
event. With it off, an identity issuance that would use the fallback is refused
before anything is recorded (409, control_plane_keygen_off, naming
subject_csr_pem, an agent-executed target, or turning the switch on), and the
console's issue review says so and takes a pasted CSR. Work queued before the
change is refused when it reaches the mint, before a key exists; it completes if
the tenant turns key generation back on. The renewal of an identity whose key the
control plane generated fails its rotation run with the same reason. The switch
also governs PKI-as-a-secret by common name and the Vault-compatible
pki/issue/{role}, the compatibility Workload API socket, and the reissue step of
an older PQC migration run (all below).
Lifecycle issuance also retains the exact public certificate template before the CA signature. The isolated signer journals that operation and returns the same signature on retry. An unfinished outbox command keeps its template and encrypted subject preparation beyond the normal idempotency retention window. Once delivery completes, normal retention can reclaim these preparation records. Retained deployment intents have their own lifetime. Historical commands that signed before this mechanism cannot reconstruct a lost certificate or key from a request ID.
If first issuance records the certificate but cannot queue its connector deployment, redelivery resumes deployment with the original encrypted key and recorded public chain. It does not sign another certificate. Recovery refuses missing key preparation, changed issuer or subject binding, mismatched material, and an expired or revoked certificate. A deployment already queued is not queued again by this issuance retry. These checks do not replace verification of the actual endpoint.
Control-plane compatibility Workload API. The production host agent now serves
the SPIFFE Workload API on the workload's machine. Its FetchX509SVID generates a
locked key before calling upward, sends only the public DER plus locally attested
selectors to the control plane, and returns the private half only over that host's
Unix-domain socket.
The control plane still composes its older Workload API socket during the
deprecation window. A workload that dials that compatibility socket receives a key
generated in the control-plane process, so that row remains Yes. Where
serve_local_workloads keeps that socket issuing, the tenant's control-plane key
generation switch still decides before every X.509-SVID key: with it off,
FetchX509SVID is refused with PermissionDenied, naming the host agent's
--workload-api-socket, and the refusal is recorded like the socket's other uses.
JWT-SVIDs carry no key and are still issued. The host-agent
Workload API is its replacement; the two rows stay separate until the compatibility
socket is removed.
PKI-as-a-secret with only a common name. /api/v1/secrets/pki now requires
exactly one custody mode.
Supply csr_pem and trstctl validates the self-signed PKCS#10 request through the
crypto boundary, signs it, and returns only the certificate; the matching key never
entered the control plane. Supplying common_name keeps the legacy brain-local convenience:
trstctl first commits an issuance.server_side_keygen deprecation receipt, then
generates and returns a keypair, while the tenant allows control-plane key
generation. If that receipt cannot become durable, issuance fails before key
generation. With the tenant's switch off, the request is refused before anything
is recorded or generated (409, control_plane_keygen_off, naming csr_pem),
and pki/issue/{role} is refused the same way, naming pki/sign/{role}. The Secrets console defaults to CSR mode and links the
legacy choice to its filtered Audit evidence. Vault/OpenBao clients use
pki/sign/{role} for CSR custody; pki/issue/{role} is the same deprecated
server-keygen choice.
Kubernetes TLS auto-issuance. A Kubernetes TLS certificate is delivered by the
host-role agent running in the cluster: a k8s_ingress_gateway source names it in
cluster_agent_id, and each TLS block becomes a k8s_secret destination with
executor=agent. The agent generates the key inside the cluster, sends only a CSR,
writes the TLS Secret, reads it back and returns a signed receipt, so the key never
leaves the cluster and the control plane never generates it. The certificate records
key_origin=host_agent and key_storage=kubernetes_secret. A k8s_secret
destination saved without executor=agent is refused, because it would route through
control-plane key generation. A source with no agent bound records each Ingress or
Gateway TLS resource it finds and reports it not_delivered with the reason; the run
completes as partial, and no key, certificate or issuance.server_side_keygen
event is made, whatever the tenant's switch says. Certificates that earlier versions
minted for these resources (with a control-plane key that was wiped, never delivered
to the cluster) stay in the inventory with key_origin=control_plane. cert-manager
against trstctl's ACME directory remains another way to serve Kubernetes TLS with a
cluster-generated key.
PQC migration reissue from an older run. A PQC migration started today binds each
certificate asset to a host target and runs as host-csr migration: the host agent
generates the post-quantum key. A run started before host-csr migration existed may
still hold reissue work whose step generates an ECDSA P-256 and ML-DSA-44 key in the
control plane, builds the hybrid CSR, and destroys the key after issuance. That step
now records key_origin=control_plane, records an issuance.server_side_keygen
event, and is refused before any key exists while the tenant's switch is off, as a
retryable no-effect; the replacement is to start the migration again with host-csr.
Device-bound claims a TPM signed
The custody record above is the control plane's own account of what it did: evidence, not an attestation. A certificate can also carry a claim the hardware holding its key signed, and that anyone can re-check without trusting trstctl's account.
Today the claim comes from ACME device-attest-01, which a certificate profile
turns on explicitly. The device's TPM attests the key it will put in its CSR
(TPM2_Certify by the TPM's attestation key, in the WebAuthn TPM format), over a
challenge that binds the tenant, account, order, nonce, identifier and the
key itself. When the attributes the TPM certified for that key say the TPM
generated it (sensitiveDataOrigin) and will release it neither to another TPM
(fixedTPM) nor under another parent (fixedParent), the issued certificate
records custody device / device_bound / non_exportable, generated by the
TPM's attestation key (tpm-ak:sha256:<digest of its certificate>), and keeps
the attestation and its binding exactly as they were verified
(certificate.custody.device_attested). An attestation of a key without those
attributes still proves the device and validates the challenge, but the
certificate keeps the requester origin: the key could be duplicated to
another TPM.
Re-check a claim offline, against TPM roots you choose:
trstctl-cli certificates key-attestation <certificate-id> > evidence.json
trstctl-cli certificates verify-key-attestation --evidence evidence.json --roots tpm-roots.pem
The verifier checks the attestation under your roots at the time it was first verified, over SHA-256 of the retained binding; that both the binding and the attested key are the certificate's key; and the key's attributes. It needs no connection to trstctl once you hold the evidence, which includes the certificate.
What the claim does not say: it is what the TPM certified when the certificate
was issued, not the device's state since, and it is only as strong as the TPM
roots you trust: a root that certifies attestation keys carelessly makes it
worthless. The claim exists for certificates issued through device-attest-01.
A host agent that generates endpoint keys in its own TPM, so certificates for
agent-executed targets carry the same claim, is what comes next.
What this page does not claim
Custody is about the subject key. It says nothing about whether a certificate reached its endpoint, which is a separate question with a separate answer, and nothing about how a requester protects the key once it has it — a key generated on your host and left world-readable is your risk, not a custody boundary trstctl can enforce.