Secrets — store, issue, rotate, and encrypt the credentials machines use
What it is
A secret is any sensitive value software needs but shouldn't expose: a database password, an API token, an encryption key. trstctl is a full secrets platform alongside its certificate work: it stores secrets encrypted, hands out short-lived ones on demand, rotates them safely, encrypts data on behalf of apps, syncs secrets to other platforms, and governs who can read or change them.
The mental model: think of a bank. The vault stores valuables encrypted (the secret store). The safe-deposit clerk issues a temporary key that self-destructs after an hour (dynamic secrets). The armored-car service moves valuables to other branches (secret sync). The teller window encrypts your deposit without you ever seeing the master key (encryption-as-a-service). And every action needs ID and is logged (auth + approvals + audit).
One honest note up front. Most of the secrets domain is now served on the running control plane: the secret store (CRUD + rotation), dynamic secret leases, one-time secret sharing, the dynamic PKI secret, machine login, secret-sync, secret scanning, workload injection, ephemeral API keys, and the Vault/OpenBao common compatibility paths are all mounted. The
/api/v1/secrets/*surface is off by default (secrets.enable_api) and fail-closed when off; ephemeral API keys live under/api/v1/ephemeral/api-keyssince they mint tenant credentials, not stored secret values. Transit encryption-as-a-service is served separately at/api/v1/transit/*(transitCLI group,keys:*RBAC scopes). KMIP is served as an opt-in mTLS listener (protocols.kmip.*) for AES-256 SymmetricKey Create/Get/Locate/Revoke/Destroy interop; remaining gaps are in Current limitations.
Why it exists
Leaked secrets are one of the most common breach causes: the traditional approach —
long-lived secrets copied into config files, environment variables, images, and CI —
spreads them everywhere and never expires them. trstctl attacks the problem from every
angle: encrypt at rest, prefer short-lived/dynamic secrets that can't be hoarded, rotate
long-lived ones automatically, never let a secret value touch a log or disk it
shouldn't, and wrap access in approvals and a tamper-evident audit trail. Secret
material lives in wipeable []byte buffers zeroed after use, never a Go string — Go
copies strings freely, so a value placed in one can linger in memory beyond your
control.
How it works
How every secret is encrypted at rest
trstctl uses envelope encryption through the single isolated
cryptography path. Each secret gets a fresh per-secret data key (DEK, AES-256-GCM),
itself encrypted under a master key-encryption key (KEK), bound to the secret's tenant
and path so a sealed blob can't be moved elsewhere. The KEK loads at startup from
TRSTCTL_SECRETS_KEK_FILE (0600), stays transient, and is zeroized. Rotating
protection means re-wrapping small DEKs, not all your data.
The native secret store (F63)
A served, tenant-isolated key-value store for application secrets: POST /api/v1/secrets/store/preview checks an exact create request without changing
anything, POST /api/v1/secrets/store creates version 1, PUT /api/v1/secrets/store/{name} writes the next version, and GET /api/v1/secrets/store/{name} reveals only the latest value to a secrets:read
caller. Metadata responses list names, versions, and timestamps only, never values.
The console's create flow has two clear steps: enter the name, owner, and value; then review the server's plan. Preview applies the same tenant-owner and duplicate name checks as create, but writes no row, event, audit record, idempotency record, outbox intent, or external effect. It never returns the value. Instead it returns a server-keyed fingerprint that changes if the tenant, caller, name, owner, or value changes. Editing any input throws away the reviewed plan. Only a current, ready plan reveals Create reviewed secret; creation remains a separate idempotent mutation.
Headless operators use the same oracle with trstctl-cli secrets store preview -f secret-create.json, then pass that exact JSON body to secrets store put. Keep the
body in a permission-restricted file or pipe it with -f -; do not place plaintext
on a shared command line or in QA evidence. A ready preview is evidence about the
request, not a reservation: the create call rechecks authority and current state.
Every create, rotation, and recovery stores a row in secret_store_versions under
PostgreSQL RLS and emits secret.version.written without plaintext.
GET /api/v1/secrets/store/history/{name}?version=N reads a prior version, and
POST /api/v1/secrets/store/recover/{name} with body {"at":"2026-06-25T12:00:00Z"}
recovers whichever version was current then as the next monotonic version, keeping
rollbacks auditable. DELETE /api/v1/secrets/store/{name} purges the current row and
sealed history for that tenant; another tenant gets a 404 — both tables are
RLS-scoped.
Values may contain ${secret.path} placeholders: a normal read returns the literal
value, while GET /api/v1/secrets/store/{name}?resolve=true expands references within
tenant/permission scope instead. Cycles like a -> b -> a return a structured 409
with the cycle path; missing references return a normal 404.
Bulk application-secret import is deliberately unavailable. The compatibility route
POST /api/v1/secrets/store/import returns 501 Not Implemented and writes nothing;
its OpenAPI operation is deprecated and marked x-trstctl-availability: unavailable.
An atomic batch command must fence, append, project, and receipt every name together
before this can be enabled without bypassing the immutable event log. Until then,
preview and then create each secret through the two native-store routes, using a
distinct idempotency key for each create mutation.
The served store seals through a versioned binary container, its KEK loaded into locked, zeroizable memory at startup, never a raw byte slice on the heap. An older store core, kept for legacy event replay and compatibility, holds its KEK the same way and still replays both the binary container and earlier JSON-envelope history; new writes go through the path above.
Vault/OpenBao-compatible common API
Teams migrating from Vault or OpenBao can point a stock vault CLI at trstctl — a
compatibility shim over the served secret store and dynamic PKI secret. Enable the same
surface (secrets.enable_api) and use a tenant API token (secrets:read/secrets:write)
as X-Vault-Token, which is what the Vault CLI sends:
export VAULT_ADDR=https://trstctl.example.com
export VAULT_TOKEN=trst_...
vault login -no-store "$VAULT_TOKEN"
vault kv put secret/payments/db username=payments password='correct horse battery staple'
vault kv get -format=json secret/payments/db
vault write -format=json pki/sign/default csr=@payments.csr ttl=1h
Supported paths are intentionally small:
| Vault path | trstctl behavior |
|---|---|
GET /v1/auth/token/lookup-self |
Validates the trst_... token; returns Vault-shaped metadata, never the token. |
KV mount-discovery preflight for secret/ |
Lets vault kv discover secret/ is KV v2. |
POST/PUT /v1/secret/data/{path} |
Upserts a KV v2 object into /api/v1/secrets/store/{path} as the next sealed version. |
GET /v1/secret/data/{path} |
Reads the latest value as Vault KV v2 data.data plus version metadata. |
POST/PUT /v1/pki/issue/{role} |
Issues a short-lived certificate and key via the signer-backed dynamic PKI secret. |
POST/PUT /v1/pki/sign/{role} |
Signs a requester-generated CSR and returns no private key. |
It skips Vault mount management, ACL policies, cubbyhole, response wrapping, transit
paths, and every dynamic secret engine — the native trstctl API remains the full
surface. The machine-readable contract is pinned separately as
docs/contracts/vault-openbao-compat.openapi.json,
since /v1 paths preserve Vault/OpenBao wire shapes. Mutating calls accept
Idempotency-Key; since the stock CLI sends none, trstctl derives one from method,
path, and body so a retry can't mint a duplicate certificate — force a fresh one with a
different Idempotency-Key, or use the native /api/v1/secrets/pki route.
The developer secrets experience (F64)
The Use secrets in apps workspace at /secrets/developer turns one secret name
into an exact, value-free access plan. Choose the secret, environment variable, and
whether references should resolve; then select Review access plan. The served
POST /api/v1/secrets/access/preview route proves the caller can open the current
tenant-scoped version, immediately wipes those bytes, and returns only metadata:
the required secrets:read permission, current version, CLI arguments, HTTP path,
TypeScript example, recovery steps, verification steps, and a server-keyed plan
fingerprint. It writes no event or audit row and makes no external call.
Only a ready, unchanged plan enables Run reviewed access test. That test performs the real served read, checks that the returned name and version still match the review, and renders a value-free receipt. A denied read can be retried with the same review. Changed inputs or a changed secret version invalidate the plan and require a new review. Missing references, cycles, wrong-tenant names, and invalid environment variables fail closed with a specific recovery step. Copyable CLI, HTTP, and TypeScript examples come from the server response, so the portal cannot drift from the API contract or invent a secret value.
The CLI exposes the same plan before the process boundary:
printf '%s\n' '{"name":"db/password","env_var":"DB_PASSWORD","resolve":false}' > access-plan.json
trstctl-cli secrets access preview -f access-plan.json
After review, trstctl-cli run fetches named secrets from the served store and runs
your program with them in the child environment, never writing the value to disk,
via the normal GET /api/v1/secrets/store/{name} RBAC path:
trstctl-cli run --secret DB_PASSWORD=db/password -- env
trstctl-cli run --resolve --secret DATABASE_URL=app/db/dsn -- ./payments-api
The --resolve flag maps to ?resolve=true; without it, a value like
${secret.app/db/password} passes through literally instead of expanding further.
After the child exits, trstctl wipes the byte-backed copies fetched — the OS
environment remains an edge string API, so use run for trusted processes and skip
debug commands printing the full environment.
Developers can read with trstctl-cli secrets store get NAME --resolve=true for the
same opt-in resolve/cycle-detect behavior, scoped to the caller's tenant and RBAC.
There is no bulk-import CLI command while the compatibility route is safely
unavailable: OpenAPI exposes only its 501 response and the portal shows a disabled
Import unavailable control with the safe alternative. Scripts must create each
secret as its own event-sourced request and use a separate idempotency key.
Dynamic secrets (F65) and PKI-as-a-secrets-engine (F67)
Instead of a long-lived secret to steal, dynamic secrets are minted on demand, scoped, and time-limited by a lease; on expiry trstctl revokes the credential automatically, even across a restart, since the revocation intent is journaled first to a durable outbox and delivered at-least-once. Eight backends ship behind one interface, each backed by real infrastructure, not a stub: PostgreSQL, MySQL, MongoDB, AWS IAM, GCP IAM, Azure Entra, Kubernetes ServiceAccount tokens, and Redis ACL users.
The control plane mounts the lease lifecycle when secrets.enable_api is on and
secret_integrations.dynamic_providers supplies tenant-bound endpoints, allowed roles,
maximum TTLs, egress policy, and file:/secret:// credential references
(buildRunDeps wires the registry). Issuance commits a pending lease and sealed
outbox command first, so only the outbox worker calls the provider and a crash retry
reuses the same identity. Cloud/Kubernetes endpoints require HTTPS; allow_insecure_loopback is a
same-host-emulator exception limited to localhost, 127.0.0.0/8, or ::1.
GET /api/v1/secrets/leases/providersreturns the eight safe setup recipes plus this tenant's real configured provider IDs, allowed roles, maximum TTLs, and runtime configuration revisions. It never returns endpoints, native bindings, credential references, or provider credential values. Configuration remains a startup-admin operation: edit the server configuration or mounted secret reference and restart the control plane; the console does not turn infrastructure credentials into browser form fields.POST /api/v1/secrets/leases/previewvalidates the exact configured ID, role, TTL, caller, tenant, and running configuration revision. It opens no credential reference, calls no provider, writes no event/audit/idempotency/outbox state, and returns the exact execution effects, recovery steps, verification steps, CLI command, and a server-keyedrequest_fingerprint.POST /api/v1/secrets/leasesissues one credential copy for a provider, role, and TTL, guarded bysecrets:writeplusIdempotency-Key. The console always supplies the current preview fingerprint; changed inputs or a restarted/reconfigured provider return409before a provider call. API clients may omit the fingerprint for backward compatibility.GET /api/v1/secrets/leases/{lease_id}returns lease metadata only, never replaying the credential after first issue.expires_atis the lease deadline: the expiry worker queues revocation on its next pass (normally every 30 seconds), and provider access can continue during delivery or retries.hard_expires_atis the original renewal ceiling, not proof of native credential expiry.revocation_statusisnone,pending,completed, orfailed; onlycompletedwithrevocation_completed_atconfirms the provider removal operation.revoked_atrecords when the control plane queued the request. Older immutable operation receipts may omit these additional fields; GET returns current durable evidence.POST /api/v1/secrets/leases/{lease_id}/renewextends a lease without returning the credential again.POST /api/v1/secrets/leases/{lease_id}/revokecloses the lease and queues backend revocation through the outbox worker.
The console presents the same contract as three small steps: Choose connection → Review exact plan → Use and retire. It selects only a real configured provider ID and an allowlisted role; the old free-text provider/role form is gone. The first step shows the selected backend's safe prerequisites and a disclosure containing all eight built-in recipes. Review must prove zero writes and zero calls before Create reviewed credential appears. The issued credential lives only in the reveal-once panel; dismissing it, navigating away, reaching the lease deadline, losing the metadata read, or revoking the lease removes the browser copy, while durable metadata remains available for renewal, revocation, and verification. The visible receipt refreshes every five seconds and on return to the tab. A revoke response acknowledges queuing; its idempotent replay retains that original result even after the provider finishes. Read current metadata and test the same login against the provider to confirm that access has ended.
PKI-as-a-secrets-engine plugs certificate issuance into the same operator
workspace. The recommended csr_pem mode accepts one self-signed PKCS#10 request,
signs it through the isolated signer, and returns only the short-lived certificate.
The mutually exclusive common_name compatibility mode generates and returns the
leaf key from wipeable memory. That mode is deprecated: its
issuance.server_side_keygen receipt must be durable before key generation begins,
and its idempotent replay returns the original sealed response without generating a
second key. The console defaults to CSR mode; Vault/OpenBao uses pki/sign for the
same custody model and retains pki/issue only as the legacy equivalent.
The console does not jump from those inputs straight to signing. Its three-step
journey is Configure certificate → Review exact plan → Verify result. The review
calls POST /api/v1/secrets/pki/preview, which uses the same profile validator as
execution but does not sign, append events, write audit, create an idempotency row,
enqueue work, or contact anything. It explains, in plain language:
- whether the secrets API, issuing CA, selected custody input, revocation tracking, and—only for the deprecated mode—durable deprecation evidence are ready;
- the exact common name and every DNS SAN, the effective lifetime after the
secrets-apiprofile cap, public-key algorithm and size, issuing-CA and CSR SHA-256 fingerprints, and matching Vault/OpenBao path; - exactly what issuance writes and calls, how to revoke/recover, how to verify, and
the equivalent
trstctl-cli secrets pkiinvocation.
The response includes a server-keyed request_fingerprint bound to the tenant,
authenticated principal, CA, custody input, profile, and requested/effective TTL.
The console supplies it as preview_fingerprint when issuing; changed input or CA
state therefore fails closed with 409 and requires a fresh review. API clients may
omit it for backward compatibility, but automation can use
trstctl-cli secrets pki preview -f REQUEST.json before the idempotent mutation.
Neither preview nor its evidence contains CSR bytes, certificate bytes, or a private
key. Richer live OCSP/CRL state and revoke diagnostics remain a documented roadmap
residual; the existing serial/revocation pipeline is not represented as richer UI
than it is.
Secret rotation (F37)
The repository contains a four-phase static-provider engine (stage, cutover, verify,
retire), but that engine keeps staged authority in process memory and cannot recover
an ACK-loss crash between provider effects. The served API therefore does not invoke
it. Before execution, POST /api/v1/secrets/rotations/preview returns an exact,
effect-free F37 plan. It reads only tenant-scoped secret metadata and configuration:
it generates no successor value, decrypts no secret, writes no event or outbox row,
and contacts no connector. The plan reports current/next versions, the resolved
connector destination, required permission, a tenant-bound request fingerprint,
execution effects, recovery steps, and explicit blockers. The console and
trstctl-cli secrets rotations preview use this plan without an Idempotency-Key;
changing console inputs invalidates the review. Only a ready, still-current review
reveals the separate execution action.
POST /api/v1/secrets/rotations currently accepts only connector:<target> and
commits one application-secret event plus its sealed outbox intent before returning
queued, non-secret evidence. Manual static-provider and dynamic-lease requests return
503 before stage, issue, cutover, delivery, verification, rollback, revoke, or
retirement. Native-store value rotation remains a separate event-sourced operation.
The scheduled path records connector cadences at
POST /api/v1/secrets/rotation-schedules, lists them with
GET /api/v1/secrets/rotation-schedules, and runs due ones with
POST /api/v1/secrets/rotation-schedules/run-due. New static and dynamic-lease
schedules fail closed with 503. A historical static or dynamic schedule is terminalized as
unsupported, disabled, and makes zero provider calls instead of pretending that
its phase chain is crash-safe. A queued connector run advances old_ref; a
connector whose local version committed before terminal delivery failure also
advances to that committed version so later cadences cannot stale-loop.
The terminal error field is a closed, status-specific evidence class, never a
provider response or wrapped error string. Empty-success statuses carry no error;
delivery and rollback failures use their exact fixed classes; unavailable and
other terminal statuses accept only their documented finite classes. Startup also
sanitizes retained schema-v1 events written by older binaries. That rewrite is
fleet-gated by TRSTCTL_SECRET_ROTATION_HISTORY_FLEET_READY, signed, sequence
preserving, and limited to the one JSON error token. Until it is safe to stop old
writers, projection catch-up, audit/search, retention, and backup export fail
closed without returning any legacy payload bytes.
The console revalidates that same closed vocabulary before rendering a successful or partial tick. An otherwise well-formed response with an unknown run, rollback, deferred, or system error is treated as a malformed scheduler failure; neither its error nor its problem detail is displayed. This keeps a stale cache or damaged proxy from turning rejected provider text back into operator-visible copy.
The manual connector form is review-first. A blocked preview explains why a provider or reference cannot run; a ready preview states what the mutation will write, which external connector it will contact, how secret material is handled, and how to retry or recover. Preview and execute remain separate server calls so viewing a plan can never become an accidental rotation.
Each run-due idempotency key owns one durable PostgreSQL tick. The tick freezes
due_through from the outer idempotency row's database created_at, the UUID-ring
cursor where scanning began, the current row snapshot, the ordered non-secret
receipt, and the exact 50-run/500-scan budgets. One tenant has one live tick owner:
every progress or terminal compare-and-swap checks its fresh lease token,
generation, and PostgreSQL-clock lease. A simultaneous same-key caller receives a
non-cached 503 in-progress error; a different live key receives a busy 503.
After expiry, the same key resumes the same cutoff, cursor, receipt, and budgets. A
different key first freezes the abandoned tick as an exact indeterminate 503,
without moving its cursor, then begins from that cursor; replaying the abandoned
key returns those retained bytes.
Before a scheduled connector effect can start, the tick saves the row it is about
to run and PostgreSQL records one immutable child command for the exact
(tenant, schedule, due_at) edge. Run, command, and terminal-event IDs are
deterministic, while each live claimant receives a fresh lease token. Overlap,
runner replacement, restart, and projection rebuild therefore resume or reconcile
the same command instead of minting another secret version. The version-3 terminal
event carries the exact due time, provider, key, old reference, command key, and
request binding. Cadence replay advances from due_at, not a producer's wall
clock; an unmatched schedule compare-and-swap rolls back terminalization instead
of manufacturing a completed command. Tick progress, receipt, cursor movement,
and an optional deferred child-lease release commit in one transaction, so a crash
exposes either all of a row's progress or none of it.
There are exactly four nonterminal deferral reasons: approval_pending,
command_in_flight, command_claimed, and config_revision_unanchored. The last
one means a schedule created before immutable configuration evidence existed must
be saved again; that scan creates no child command and calls no provider. Every
deferral keeps the same due edge and is returned with schedule ID, reason, due time,
and optional error; later due rows are still scanned. The stable UUID ring wraps at
most once per tick. One tick executes at most 50 rotations and scans at most 500
rows. Landing exactly on either bound is reported conservatively as
run_limit_reached or scan_limit_reached, with
complete:false, because only an under-budget empty or short ring proves that no
due row remains. The console names the consumed bound and tells the operator to run
the next tick from the durable cursor.
If shared store, event, custody, or integrity state fails after earlier rows finish,
the endpoint returns a truthful 503 envelope with complete:false,
partial:true, the failed schedule ID, and the system error. The outer idempotency
record caches that whole envelope: replaying the same key is byte-identical and
executes no child command; a new key reconciles any retained terminal event and
continues later rows. A tick's composite foreign key keeps its bound outer
idempotency row out of normal garbage collection. The indeterminate takeover
receipt stays until its original key returns and completes the outer row; deleting
that completed outer row then cascades to the tick. Terminal child-command
receivers are garbage collected only after their exact immutable version-3
terminal event is still retained, the schedule has advanced past that due edge,
and a newer command exists. The newest command remains as a finite
one-row-per-schedule lineage fence; claimed or ambiguous commands are never purged.
The endpoint currently accepts one provider class:
connector:<target>atomically commits a native-store version and one sealed secret-sync outbox command. The response setsqueued:trueandcompleted:false; only the bounded outbox worker calls the connector. Delivery failure stays on the durable job for retry and does not perform a compensating direct write.
Static providers such as postgresql, mysql, and aws-iam, plus
dynamic-lease:<provider>, fail closed with a stable 503 before any provider,
connector, event, or outbox effect. Issuing, renewing, and revoking dynamic leases
remain supported through the lease endpoints. Provider rotation stays unavailable
until one durable worker command can autonomously own every effect and compensation
across every crash boundary.
PostgreSQL, MySQL, and AWS IAM rotators ship as concrete, infrastructure-verified library backends covering stage/cutover/verify/retire-and-rollback; the served request path deliberately does not invoke them yet.
Ephemeral API keys (F38)
For high-churn automation, trstctl issues short-lived API keys through the served control plane:
POST /api/v1/ephemeral/api-keys/previewvalidates and normalizes the exact tenant/caller/subject/scopes/TTL plan without creating a bearer, event, idempotency row, projection, or external effect. It returns a server-keyedrequest_fingerprintand complete recovery/verification instructions.POST /api/v1/ephemeral/api-keysmints a tenant API token withsubject,scopes,ttl_seconds, and an optional reviewedpreview_fingerprint. A stale fingerprint returns409before issuance.trstctl-cli ephemeral api-keys preview -f body.jsonandephemeral api-keys issue -f reviewed.jsondrive the same two-step contract.- Both review and issue require
access:write; both also attenuate authority by refusing any requested scope the caller does not already possess. - Execution is guarded by
Idempotency-Key, so a byte-identical retry with the same key returns the original response instead of minting twice. Reusing that key with changed input returns409. - The response returns the raw
trst_...token once; the event log stores only the token hash inapi_token.created— the raw token is never persisted or emitted. - The served leaseworker sweeps expired keys and emits
api_token.revoked, so the read model showsrevoked_atevidence and authentication rejects the key after TTL. - The console makes the whole lifecycle executable: configure, effect-free review, reviewed issue, metadata-ledger observation, same-key recovery after uncertainty, bearer-only verification, immediate revocation, and post-revoke cleanup. The raw bearer lives only in React memory and is removed on dismiss or revoke.
{
"subject": "ci-preview-deploy",
"scopes": ["access:read"],
"ttl_seconds": 900,
"preview_fingerprint": "sha256:<value returned by preview>"
}
Use it for short CI jobs, deploy previews, partner imports, and similar workflows needing a narrow bearer credential for minutes, not a reusable key.
Encryption-as-a-service & KMIP (F66)
Transit encrypts, decrypts, HMACs, signs, verifies, and rewraps data using tenant-scoped
named keys the application never sees, mounted at /api/v1/transit/* with a
one-for-one CLI: GET /api/v1/transit/keys / transit keys list reads only
tenant-scoped name, purpose, and current-version metadata;
GET /api/v1/transit/keys/{name}/versions / transit keys versions <name> reads
the complete retained version history without key material; GET /api/v1/transit/status / transit status reads effect-free sealed-restore and
KMIP runtime posture;
POST /api/v1/transit/keys / transit keys create mints a key,
.../keys/rotate / transit keys rotate rotates it, and the same
POST /api/v1/transit/<op> / trstctl-cli transit <op> pairing covers encrypt,
decrypt, rewrap, hmac, sign, and verify.
Ciphertexts are versioned (trv:<version>:...) so rotation can rewrap old data to the
newest version. Requests are tenant-bound and idempotent, auth-gated by keys:write
for creation/rotation/encrypt/decrypt/rewrap/HMAC/sign and keys:read for verify.
Plaintext and associated data live in wipeable []byte buffers zeroized after the
response is written. When transit.keyring_dir and the deployment KEK are configured,
every create/rotate atomically checkpoints an opaque sealed keyring and startup restores
it before serving; a missing or unreadable original KEK fails startup instead of silently
replacing the keys. There is deliberately no browser upload/import path for key material.
Events —
transit.key.created, transit.key.rotated, transit.encrypt, transit.rewrap,
transit.hmac, transit.sign — give audit evidence without logging key bytes or
plaintext.
The console's Encryption and signing task at /secrets/engines reads back safe
key metadata, creates AEAD/HMAC/signing keys, selects only a compatible key for each
operation, rotates the selected key of each type, and operates encrypt, decrypt,
rewrap, HMAC, sign, and verify. It also shows the full retained version history,
Transit-only immutable audit receipts, automatic sealed-restore state, an explicit
proof-first failed-operation recovery journey, and the real tenant-bound KMIP
listener/profile status. Transit is a separate encryption service, so those controls
remain available when the optional native secret store is disabled. No key bytes
enter the browser. The raw plaintext returned by decrypt is shown in a reveal-once
panel and removed from the page when dismissed.
cat > transit-key.json <<'JSON'
{"name":"payments","kind":"aead"}
JSON
trstctl-cli --idempotency-key transit-payments-create transit keys create -f transit-key.json
trstctl-cli transit keys list
trstctl-cli transit keys versions payments
trstctl-cli transit status
cat > transit-encrypt.json <<'JSON'
{"key":"payments","plaintext":"Y2FyZC10b2tlbi0xMjM=","aad":"dGVuYW50PXBheW1lbnRz"}
JSON
trstctl-cli --idempotency-key transit-payments-encrypt transit encrypt -f transit-encrypt.json
If a create, rotate, encrypt, rewrap, HMAC, or sign response is uncertain, do not
invent a replacement key or changed request. Keep the original inputs and
Idempotency-Key, read transit status, the key's version history, and the filtered
transit.* audit receipts, then replay the byte-identical request with the same key.
For a restore failure, repair the original KEK or sealed-keyring mount and restart;
never delete or overwrite the sealed file just to make the process start.
For legacy gear, the binary can also mount an opt-in KMIP listener:
TRSTCTL_PROTOCOLS_KMIP_ENABLED=trueTRSTCTL_PROTOCOLS_KMIP_TENANT_ID=<tenant-uuid>TRSTCTL_PROTOCOLS_KMIP_ADDR=:5696TRSTCTL_PROTOCOLS_KMIP_CERT_FILE=/path/server.crtTRSTCTL_PROTOCOLS_KMIP_KEY_FILE=/path/server.keyTRSTCTL_PROTOCOLS_KMIP_CLIENT_CA_FILE=/path/client-ca.crt
The listener is raw KMIP over mutual TLS 1.3; the TLS layer verifies the client cert
chain before the KMIP handler sees a frame. The service stores objects under the
configured tenant, emits immutable kmip.object.created, kmip.object.revoke, and
kmip.object.destroyed audit events, and zeroizes in-memory key material on destroy,
rekey, and shutdown. The served OASIS 1.4 profile is stock-client-tested: Query,
DiscoverVersions, Create/Register an AES-256 SymmetricKey, Get it plain or
AES-GCM-wrapped (and register the wrapped value back), Locate, Revoke, and Destroy over
TTLV. Unsupported operations get a KMIP failure response, not an unframed TCP close.
The Transit status endpoint and console report not_configured, starting, or
listening from the assembled tenant binding and live listener state. That status is
not a conformance claim: use a stock KMIP client to prove the complete mTLS wire path.
Secret sync (F68)
trstctl pushes secrets outward via the durable outbox (journaled first, at-least-once,
no half-writes). Operators first call POST /api/v1/secrets/syncs/preview. Preview
checks only the tenant-scoped secret metadata and configured target catalog. It does
not open the secret value, emit an event, write an outbox row, or contact the target.
For a ready plan it returns the exact secret version, destination, effects, recovery
steps, verification steps, and a caller-bound request fingerprint.
POST /api/v1/secrets/syncs reads the stored secret only after the operator submits
that reviewed fingerprint, writes a sealed
outbox row in the same tenant-scoped transaction, delivers through the configured
pusher, and returns metadata only (name, target, remote_key, enqueued/delivered
flags). GET /api/v1/secrets/syncs/targets lists the catalog and marks which targets
are configured. Shipped concrete pushers: AWS Secrets Manager, GCP Secret Manager,
Azure Key Vault, GitHub Actions, GitLab CI, Vercel, Kubernetes, Terraform Cloud/OpenTofu,
HashiCorp Vault/OpenBao KV v2, and a generic CI/JSON endpoint.
Every redelivery reuses the same sync-operation ID — AWS Secrets Manager sends it as
both ClientRequestToken and Idempotency-Key; GCP Secret Manager and Azure Key Vault
compare the current version before creating another, forwarding the ID too — so a
crash between commit and acknowledgement reconciles to the existing value instead of
duplicating, rejecting any changed replay outright.
Commands are FIFO across the whole tenant+target, not merely one spelling of a
remote key: receivers commonly normalize /TOKEN/ and TOKEN to the same object.
Each new command copies its positive immutable event sequence into the projected job
and outbox row; a retry/backoff therefore blocks later same-target commands while a
different target can progress. Terminal delivery evidence is itself a deterministic
event receipt, and startup compares retained history with SQL before workers run.
Immediately before receiver I/O, the worker records a monotonic command-global
start token under the same tenant+job terminal-choice lock. A failed event is safe
only when durable authority proves no generation could have changed the receiver;
an ordinary timeout or expired worker remains effect_possible, keeps its FIFO
barrier, and is retried with the same receiver idempotency key until delivery is
proved. The one exception is a typed local no-network result owned by the first and
still-only start token. Its closed error and event attempt count are frozen before
append so crash recovery reproduces the same canonical evidence bytes.
The command ID also includes the tenant's secret lifecycle epoch, so offboarding and
re-registering the same tenant UUID cannot reactivate old ciphertext or outcomes.
Worker capacity and fairness use the tenant plus that effective target lane. Two
tenants may both call a target ci, but one tenant's slow ci receiver does not
consume the other tenant's ci lane or circuit allowance. Within either tenant,
the whole target remains serial and FIFO, including remote-key aliases. The bounded
secret-sync family worker pool and queue still cap aggregate work across tenants and
reject promptly when that global provider-plane budget is full.
Targets are configured under secret_integrations.sync_targets, one tenant/credential
reference each, resolved for a single outbox attempt before the locked buffer is
destroyed. GitHub values use its X25519/XSalsa20-Poly1305 sealed-box format — sealed
locally with the repo's public key, decryptable only by the matching private key's
holder, never sent as plaintext. Sync endpoints follow the dynamic-provider transport
rule: HTTPS by default, allow_private_endpoint is only an address grant, plaintext
needs the explicit loopback-only switch.
AWS, GCP, and Azure workload identity for secret sync
Each cloud is configured independently on its matching sync target:
aws_workload_identity, gcp_workload_identity, or
azure_workload_identity. Every switch is false by default. Enabling one forbids
that target's static credential fields, so a missing workload-identity source fails
closed instead of falling back to a long-lived token or access key. The three
provider exchanges are thin, hand-written REST encoders over one shared
internal/cloudauth cache and refresh-before-expiry seam; there is no cloud vendor
SDK or parallel authentication stack.
AWS Secrets Manager targets can replace long-lived access keys with an explicitly
configured workload identity. Set aws_workload_identity: true on that target and
leave access_key_id, secret_access_key_ref, and session_token_ref empty.
workload_identity_endpoint is optional and defaults to the AWS STS endpoint; it is
primarily useful for an approved private endpoint or test substrate. The default is
still off, so existing static-credential targets do not change and a fresh install
makes no token-exchange egress.
An operator then creates a tenant source at
/api/v1/secrets/syncs/workload-identity-sources (or with
trstctl-cli secrets syncs workload-identities create --body-file ...). The source
binds exactly one configured AWS target to an IAM role ARN, expected OIDC issuer
trust source, audience, subject, allowed remote-key prefixes, and a file: or
secret:// proof reference. The API stores only this configuration and honest
ready, active, disabled, offline_disabled, or exchange_failed status; it
never accepts or stores an inline proof or AWS token. The console under Secrets
serves create, list, edit, delete, expiry, and failure-state workflows.
Only the bounded secret-sync outbox worker resolves the proof, validates its signature
and exact issuer/audience/subject/expiry against the tenant's JWT/JWKS trust source,
and performs AssumeRoleWithWebIdentity. The shared internal/cloudauth minter
caches the short-lived result until its refresh window, keeps secret bytes locked and
wipeable, and passes them into the existing hand-written AWS SigV4 pusher. No vendor
SDK or parallel AWS integration is involved. When air-gap policy is enabled, the
worker records offline_disabled before opening a connection, marks that delivery
failed once with a stable reason, and does not retry forever.
GCP Secret Manager targets use the same boundary with
gcp_workload_identity: true and no token_ref.
workload_identity_endpoint defaults to the RFC 8693 endpoint at
sts.googleapis.com. A GCP source leaves role_arn empty and may set
service_account: when it is blank, the worker passes the short-lived STS bearer
to the existing GCP pusher; when it is present, the worker performs the optional
IAM Credentials generateAccessToken step at the configured
workload_identity_impersonation_endpoint. Both paths resolve and validate the
OIDC proof, exchange it, cache it, and refresh it through the same
internal/cloudauth minter used by AWS. The API, CLI, generated clients, and
console expose the same tenant-scoped source and honest runtime status.
Azure Key Vault targets use that same boundary with
azure_workload_identity: true and no token_ref.
workload_identity_endpoint is optional; when absent, the source's
azure_tenant_id selects
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token. The Azure source
sets provider: "azure", the Entra application client_id, and a Key Vault
target_scope, normally https://vault.azure.net/.default, in addition to the
same trust source, exact audience/subject, target, allowed remote-key prefixes, and
proof reference used by the other clouds.
After the outbox worker validates the referenced OIDC proof, the thin Entra
exchange sends grant_type=client_credentials, the application client ID, target
scope, and that existing proof unchanged in the JWT-bearer client_assertion
field. This is the Entra federated-credential flow: trstctl does not mint a second
JWT, sign an assertion with a certificate, or hold an Entra certificate/private
key. The returned short-lived bearer goes through the existing hand-written
internal/secretsync Azure Key Vault pusher. It replaces this sync target's
static token_ref; the similarly named
TRSTCTL_MANAGED_KEYS_AZURE_BEARER_TOKEN(_FILE) settings and
internal/kms/azurekv are a separate managed-key child-signer path.
Like the other two providers, Azure exchanges only during a bounded secret-sync
outbox delivery. The request handler merely journals the work. Air-gapped mode records
offline_disabled before any Entra or Key Vault network request, fails the queued
delivery once with a stable reason, and does not retry forever. The served Azure
proofs also verify tenant isolation and that neither the OIDC proof nor minted bearer
reaches the API response, event log, outbox payload, job error, or source status.
See Secrets configuration for
the complete target and source fields.
GET /api/v1/secrets/cloud-secret-managers / trstctl-cli secrets cloud-secret-managers: read-only cloud_secret discovery for AWS Secrets Manager, GCP
Secret Manager, Azure Key Vault, and HashiCorp Vault KV, plus sealed-outbox sync
coverage for AWS/GCP/Azure — counts, operations, evidence, residuals only, never
values, credentials, or tokens.
GET /api/v1/secrets/kubernetes-operator / trstctl-cli secrets kubernetes-operator:
the TrstctlSecretSync CRD declares the target Kubernetes Secret, the trstctl
references to resolve, and which Deployment/StatefulSet/DaemonSet workloads
reload via a pod-template hash annotation; the operator writes Secret.data and
records status.phase, status.contentHash, and status.reloadedWorkloads —
metadata only.
GET /api/v1/secrets/workload-injection / trstctl-cli secrets workload-injection:
the TrstctlSecretInjection CRD consumes a namespace-local Kubernetes Secret and
patches Deployment/StatefulSet/DaemonSet pod templates with a memory-backed
shared volume, app-container mounts, optional valueFrom.secretKeyRef env entries,
and the trstctl-agent --secret-inject sidecar; the operator reads only
metadata/content hash, and the sidecar copies volume files as byte slices and wipes
buffers.
GET /api/v1/secrets/unvaulted / trstctl-cli secrets unvaulted: repository and
third-party scanning sources, redacted leaked_secret finding counts, cloud-secret
discovery across AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, and
HashiCorp Vault KV, and configured AWS/GCP/Azure sync targets — read-only and
metadata-only.
The auth-method framework (F58)
Before reading a secret, a workload must authenticate to trstctl via the auth-method framework: it presents a credential (a token, an OIDC JWT, a Kubernetes SA token, cloud IAM, etc.), trstctl verifies it through the single isolated cryptography path (timing-safe), and issues a scoped, time-bounded session bearer. Credential bytes are never logged (wipeable memory, never a copyable string). The raw bearer is shown once; only its one-way hash enters the event-sourced API-token projection. Successful sessions and operator revocations become tenant-scoped evidence in the durable session ledger, and revocation atomically disables the linked bearer. Authentication failures return only a generic denial.
POST /api/v1/secrets/login serves seven machine methods — token, kubernetes,
aws-iam, gcp, azure, oidc, jwt. JWT-family methods verify against an
operator-supplied JWKS, check issuer/audience/expiry, and bind a tenant claim or pin the
method to one tenant. Each configured method must grant non-empty static scopes,
or OIDC/generic JWT may name one signed scopes_claim; setting both or neither
fails startup. A token's ordinary scope/scopes claim never overrides static
operator policy. AWS IAM uses the Vault-style signed sts:GetCallerIdentity
request, verifying the caller via STS without ever receiving the AWS secret access key.
secrets:
enable_api: true
# Optional builtin HMAC token method. Its deployment-wide verifier is usable
# only for this tenant and grants exactly these scopes.
auth_secret_file: /etc/trstctl/machine-auth.bin
auth_token_tenant_id: 11111111-1111-1111-1111-111111111111
auth_token_scopes: ["secrets:read"]
machine_auth:
- name: kubernetes
tenant_claim: trstctl.io/tenant
issuer: https://kubernetes.default.svc
audience: trstctl
jwks_file: /etc/trstctl/k8s-sa-jwks.json
allowed_namespaces: ["payments"]
allowed_service_accounts: ["payments/api"]
scopes: ["secrets:read"]
- name: aws-iam
tenant_id: 11111111-1111-1111-1111-111111111111
allowed_accounts: ["123456789012"]
scopes: ["secrets:read"]
The /secrets/access console turns that backend framework into one complete,
review-first journey:
- Choose method. Select one method from the server's secret-free method inventory. The console shows its real type, source, issuer/audience rules, tenant restrictions, and enabled/disabled overlay. Method definitions remain deployment-owned configuration; the browser cannot invent or silently edit an authentication authority.
- Review login.
POST /api/v1/secrets/login/previewreceives only the method name. It reads configuration and returns the exact tenant binding, credential shape, session lifetime, prerequisites, blockers, writes, outside calls, recovery steps, verification steps, and CLI command. It does not receive a credential, call the verifier, create a session/event/idempotency row, or contact AWS STS. - Test reviewed login. Only a ready plan reveals the password-style credential
field.
POST /api/v1/secrets/loginconsumes the credential once and requires anIdempotency-Key; the request is bound with a server-keyed digest of the tenant, method, credential, and review fingerprint. An exact retry returns the original response—including the same one-time bearer—instead of issuing a second session. Reusing the key for different input returns409. The presented credential and raw session bearer never enter an event or read model. - Verify and recover. The result shows session id, principal, method, scopes,
expiry, and a one-time reveal panel for the
trst_…bearer. Copy it directly into the workload, then dismiss it; trstctl cannot recover it from the stored hash. The page refreshes Issued sessions so the durable row can be checked and revoked. Revocation immediately makes the linked bearer fail authentication. The browser clears the presented credential after both success and failure. A normal authentication failure keeps the reviewed plan for a corrected retry; a409configuration/fingerprint change discards it and requires a fresh review. A disabled method shows blockers and never accepts a credential.
Headless users get the same contract. Keep credential JSON in a permission-restricted file or provide it through a protected pipe; do not put credentials in shell history:
trstctl-cli secrets login preview -f machine-login.json
trstctl-cli --idempotency-key workload-login-20260902 secrets login -f machine-login.json
trstctl-cli secrets sessions list
trstctl-cli --idempotency-key revoke-synthetic-session-1 secrets sessions revoke SESSION_ID
Preview requires secrets:read because method configuration is operator metadata.
The login exchange itself is intentionally public: the presented machine credential
authenticates the workload, while X-Tenant-ID remains only a lookup hint and never
grants access. The session list/revoke and method enable/disable operations remain
RBAC-protected. This preserves CA/provider agnosticism: the framework validates the
credential against the configured authority but does not require trstctl to have
issued it.
Secret scanning bridge (F39) and sharing & approvals (F60)
The scanning bridge runs the pinned Gitleaks scanner from the served control plane,
recording redacted findings into discovery, the
credential graph, and the risk view. TRSTCTL_SECRETS_GITLEAKS_BIN
points at the Gitleaks v8.27.2 binary from tools/gitleaks/install.sh, which
checksums the release tarball before installing. secrets.scan_roots (or
TRSTCTL_SECRETS_SCAN_ROOTS, a comma-separated list) is the closed set of mounted
filesystem trees the served scanner may read; an empty setting confines scans to the
control-plane working directory. This prevents a caller with secrets:write from
turning the scanner into a general host-file reader. POST /api/v1/secrets/scans/preview first validates the exact confined path, scan mode,
installed scanner, and additive rules without starting Git or Gitleaks, creating a
file, appending an event, or recording idempotency. Its keyed fingerprint is bound to
the tenant, caller, normalized target, mode, rules path, and capabilities. POST /api/v1/secrets/scans may require that fingerprint and fails with 409 before the
scanner starts if the reviewed command changed. Execution scans a repo or workspace
with the pinned 213-rule default set (above the 140-rule floor);
the response and stored finding carry only rule id, file, line, scanner version, and
fingerprint — Gitleaks redacts the value, never reaching the API, event log, graph, or
audit output.
cat > secret-scan.json <<'JSON'
{"path":".","mode":"git_history","custom_rules_path":"./gitleaks-custom-rules.toml"}
JSON
trstctl-cli secrets scans preview -f secret-scan.json
# Copy request_fingerprint from the review into preview_fingerprint, then run.
trstctl-cli --idempotency-key ci-secret-scan-1 secrets scans run -f reviewed-secret-scan.json
A bare {"path":"."} scans the working tree by default; mode/custom_rules_path
above switch on full Git-history scanning (--log-opts --all) plus an additive
[[rules]] TOML fragment wrapped with [extend] useDefault = true — never an
allowlist or disabled-rule override. The response's mode, custom_rules, and
capabilities fields prove full-history, entropy, pattern, 100+ default-rule, and
custom-rule coverage without storing a secret value; run_id can be replayed against
GET /api/v1/discovery/findings?run_id=... or the graph view. TruffleHog JSON
ingestion still exists for offline import/contract tests, but Gitleaks is the served
engine. If a scanner or network failure interrupts execution, retry the identical
reviewed body with the same Idempotency-Key: a pre-recording failure can try again,
while a completed request returns its original run instead of scanning twice. The
console preserves that recovery key only in memory, keeps the original failure
visible, and labels the action Retry reviewed scan.
Locally, the same runner works without a server: secrets scans staged-diff and
secrets scans pre-commit install scan only staged Git blobs, or an explicit
base/head CI diff, into a temporary tree, dropping raw values from stdout, stderr, and
JSON output. Findings block commits and pipeline steps by default; --advisory keeps
the report but exits zero for non-blocking rollout:
trstctl-cli secrets scans staged-diff --repo . --base origin/main --head HEAD --advisory
For realtime repo ingress, GET /api/v1/secrets/scans/repositories reports provider
posture for GitHub, GitLab, and Bitbucket; POST /api/v1/secrets/scans/repositories/{provider}/webhook takes a normalized event —
repository, checkout_path, ref, commit_sha, event, credential_ref — and
upserts a tenant-scoped secret_repo discovery source plus a discovery.run outbox
row. The outbox worker scans checkout_path directly, or clones a public/local
clone_url; credential-bearing URLs are rejected, and private credentials must stay
secret references, never payload values. Native GitHub/GitLab/Bitbucket signature
verification and private credential_ref clone resolution remain shortfalls, not
served.
Third-party artifacts use the same shape: GET /api/v1/secrets/scans/third-party and
POST /api/v1/secrets/scans/third-party/{provider}/ingest cover cicd_log,
container_registry, slack, and jira — a source plus operator-owned
artifact_path queues a secret_third_party discovery run against the same Gitleaks
runner. Raw CI logs, registry exports, chat transcripts, and issue exports stay outside
trstctl storage; events record only provider, artifact kind, rule id, file, line,
scanner version, and credential-ref. Native Slack/Jira/container-registry API polling,
signature validation, and provider-native annotations remain shortfalls, not served.
cat > third-party-scan.json <<'JSON'
{"source":"acme/slack","artifact_path":"/var/lib/trstctl/exports/slack.jsonl","event":"message_export"}
JSON
trstctl-cli --idempotency-key third-party-scan-1 \
secrets scans third-party ingest slack -f third-party-scan.json
Secret sharing is a review-first, one-time delivery journey. POST /api/v1/secrets/shares/preview accepts only ttl_seconds: the value never enters the
preview request. It returns the normalized lifetime, permission, exact execution
writes, recovery and verification steps, empty preview/external-effect lists, and a
tenant-and-principal-bound fingerprint. The preview creates no share, event, audit
row, idempotency record, signer request, outbox work, or external call. Zero selects
the 24-hour default; explicit lifetimes must be between 60 seconds and 7 days.
POST /api/v1/secrets/shares accepts the value only at execution and may bind it to
the reviewed lifetime with preview_fingerprint. A changed lifetime then fails with
409 before any state change. PostgreSQL stores only SHA-256(token) plus the
envelope-encrypted value in secret_shares; the raw bearer token is returned once. A
valid share survives a restart, while a stolen database or backup holds neither the
token nor plaintext. If the response is interrupted, retry the identical request with
the same Idempotency-Key: trstctl returns the exact original token instead of
creating a duplicate. Reusing that key for changed input fails closed.
POST /api/v1/secrets/shares/redeem requires the share token plus an authenticated
principal with secrets:read in the same tenant. It atomically deletes the row and
returns the value for one redemption; another new request, expired token, or wrong
tenant gets a normal 404. A retry of the identical authenticated request with its
original Idempotency-Key recovers the original response. The console retains that
key only in memory after an ambiguous network/server error and offers Retry same
redemption. Changing the token or leaving the page discards this recovery key.
This is authenticated token delivery, not an anonymous share URL. Individual share
cancellation before consumption or expiry is not currently served.
The /secrets/sharing console exposes the same configure → review → create → reveal →
redeem/verify path, keeps the value out of the preview, and preserves each recovery key
only in memory while an ambiguous request is retried. Neither value nor token is
written to browser storage or product evidence.
Change approvals appear beside sharing and reuse the same dual-control
approval store
as privileged issuance: with ca.policy.require_approval enabled, rotate/recover/
delete mutations open a tenant-scoped approval request and fail with 403 until
enough distinct approvers approve it, and a requester can't approve their own change.
Approvers call POST /api/v1/secrets/store/approvals/{name} with {"action":"rotate"},
{"action":"recover"}, or {"action":"delete"}; the response holds only resource,
action, approver, and the approval count.
In the console
The console renders the native store at /secrets and separate task-focused workspaces
for machine access, one-time sharing, engines, scanning, and delivery. Transit lives in
the Encryption and signing task at /secrets/engines; the exact console/API
boundary is listed above. Import stays disabled because its compatibility route returns
501 without writing. See The web console.
Use it
Served workflows run through the API/CLI; embedders can use the same lower-level Go interfaces directly, shown here in byte-oriented shape:
store.Put(ctx, "db/password", []byte("s3cr3t"), "idem-key") // -> version 1
val, _ := store.Get(ctx, "db/password") // latest live version
lease, _ := dyn.Issue(ctx, "postgresql", "readonly", time.Hour, "req-1") // auto-revoked
ct, _ := keyring.Encrypt(ctx, "app-key", []byte("hello"), nil) // -> "trv:1:..."
The secretstore.APIServer exposes the store over HTTP (PUT/GET /secrets/<path>, with
Idempotency-Key and tenant headers) once mounted.
cat > secret-sync.json <<'JSON'
{"name":"sync/source","target":"github-actions","remote_key":"DB_PASSWORD"}
JSON
# Effect-free: reads metadata only and contacts no target.
trstctl-cli secrets syncs preview -f secret-sync.json
# Copy request_fingerprint from preview into preview_fingerprint, without adding a
# secret value. Execution rejects a stale fingerprint if the secret version,
# destination, caller, or remote key changed after review.
cat > reviewed-secret-sync.json <<'JSON'
{"name":"sync/source","target":"github-actions","remote_key":"DB_PASSWORD","preview_fingerprint":"sha256:<copy-from-preview>"}
JSON
trstctl-cli --idempotency-key sync-db-password-1 secrets syncs run -f reviewed-secret-sync.json
curl -fsS -H "Authorization: Bearer $TRSTCTL_TOKEN" \
"$TRSTCTL_URL/api/v1/secrets/syncs/targets"
Pitfalls & limits
- Serving status: rotation serves worker-queued connector-backed manual and
scheduled variants. Static-provider and dynamic-lease rotation fail closed before
provider effects pending a durable multi-phase worker command. An unconfigured
secret-sync target fails closed with
503instead of dropping the write; a missing Gitleaks binary fails scanning closed with503too. - Machine login tenant binding: token credentials MAC-bind the tenant, the
machine-loginaudience, principal, and expiry.X-Tenant-IDis a lookup hint on the public login route; a token for tenant A is rejected if presented with tenant B. The builtin HMAC verifier itself must also name exactly oneauth_token_tenant_idand one or moreauth_token_scopes; startup rejects an unpinned or unscoped authority. - Protect the KEK. Everything at rest is only as safe as
TRSTCTL_SECRETS_KEK_FILE; in production back it with an HSM/KMS. - Dynamic beats static. Prefer dynamic/ephemeral secrets over long-lived ones; if you must store one, put it on a rotation schedule.
- Transit/KMIP boundaries: appliance-specific templates and tenant self-service KMIP listener management remain deliberate boundaries — see Current limitations.
- Sync is push + drift-detect, not a two-way merge — trstctl is the source of truth.
Reference
- At rest: envelope encryption (AES-256-GCM DEK wrapped by KEK); config
TRSTCTL_SECRETS_KEK_FILE. - Store:
Put/Get/GetVersion/Versions/Rollback/Delete/Purge;APIServer(PUT/GET /secrets/<path>,Idempotency-Key). - Developer run wrapper:
trstctl-cli run --secret ENV=secret/path -- <cmd>fetches via/api/v1/secrets/store/{name}and injects only into the child env. - Dynamic backends:
postgresql,mysql,mongodb,aws-iam,gcp-iam,azure-entra,kubernetes,redis, pluspki. - Transit:
/api/v1/transit/{keys,encrypt,decrypt,rewrap,hmac,sign,verify},trstctl-cli transit ..., versionedtrv:<n>:ciphertext. - KMIP: opt-in mTLS listener (
TRSTCTL_PROTOCOLS_KMIP_ENABLED=true) for AES-256 SymmetricKey Create/Get/Locate/Revoke/Destroy, default address:5696, tenant bound byTRSTCTL_PROTOCOLS_KMIP_TENANT_ID. - Sync targets: AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, GitHub Actions, GitLab CI, Vercel, Kubernetes, Terraform Cloud/OpenTofu, HashiCorp Vault/OpenBao KV v2, generic CI/JSON.
- Scanning:
GET /api/v1/secrets/scans/repositories,POST /api/v1/secrets/scans/repositories/{provider}/webhook,GET /api/v1/secrets/scans/third-party,POST /api/v1/secrets/scans/third-party/{provider}/ingest,POST /api/v1/secrets/scans;trstctl-cli secrets scanssubcommandsrepositories,repositories webhook,third-party,third-party ingest,run,staged-diff,pre-commit install; Gitleaksv8.27.2,213default rules active, workspace and full-Git-history modes, additive custom[[rules]]fragments, CI-log/container-registry/Slack/Jira artifact ingress, redacted findings only. - Events:
secret.version.written,rotation.*,rotation.rollback_failed,secret.rotation.connector_cutover,secret.rotation.connector_rolled_back,secret.rotation_schedule.upserted,secret.rotation_schedule.ran,auth.session.issued,discovery.finding.recorded,discovery.run.completed.
See also
Workload identity (attestation behind ephemeral secrets) · Issuance & certificate authorities (HSM-backed KEK; PKI engine) · Incident response & JIT (compromise + approvals) · Discovery & inventory (finding existing secrets) · Current limitations · glossary: secret, envelope encryption, KEK/DEK, dynamic secret, lease, transit, KMIP
Covers: F37, F38, F39, F63, F64, F65, F66, F67, F68, F58, F60