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 creates version 1, PUT /api/v1/secrets/store/{name} writes the
next version, GET /api/v1/secrets/store/{name} reveals only the latest value to a
secrets:read caller, and metadata responses list names, versions, and timestamps
only, never values.
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.
POST /api/v1/secrets/store/import bulk-imports a body like
{"prefix":"app","values":{"db/user":"svc","db/dsn":"postgres://${secret.app/db/user}@db"}}.
Each value is sealed independently as version 1, the response is metadata-only, and if
any imported name already exists the whole import is rejected so a tree cannot
half-land.
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/issue/default common_name=payments.internal 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. |
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)
Two pieces make secrets pleasant and safe for developers. trstctl-cli run fetches
named secrets from the served store and runs your program with them in the child
environment, never written to disk, via the normal GET /api/v1/secrets/store/{name}
RBAC path — only names and paths are audited, never values. An SDK caches secrets,
auto-refreshes before expiry, and on revocation evicts the cache and fails safe instead
of serving a stale one.
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 also load configuration as a tree with trstctl-cli secrets store import --body-file import.json, or 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.
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.
POST /api/v1/secrets/leasesissues one credential copy for a provider, role, and TTL, guarded bysecrets:writeplusIdempotency-Key.GET /api/v1/secrets/leases/{lease_id}returns lease metadata only, never replaying the credential after first issue.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.
PKI-as-a-secrets-engine plugs the same lease machinery into certificate issuance: a short-lived certificate is requested exactly like a database password, and the leaf key is generated in wipeable memory and zeroed immediately after use.
Secret rotation (F37)
The rotation engine replaces a long-lived secret in four rollback-safe phases:
stage, cutover, verify, retire. A failed cutover or verification auto-rolls-back so the
application is never left broken; if rollback itself fails, the report sets
RollbackAttempted and RollbackFailed, leaves RolledBack false, and audits
rotation.rollback_failed so operators know the consumer may need intervention.
trstctl serves this at POST /api/v1/secrets/rotations — the request names a provider,
consumer key, and current backend reference; the response returns only non-secret
evidence (old_ref, new_ref, completed/rolled-back flags, failure phase). The
scheduled path records 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, advancing old_ref only after a
completed rotation.
The same endpoint accepts three backend classes:
- Static providers such as
postgresql,mysql, andaws-iamrotate a long-lived backend credential and publish it to the configured consumer pointer. connector:<target>rotates a native-store secret version, pushes the new value through the configured secret-sync outbox target, and restores the priorold_ref(version:<n>) if connector delivery fails.dynamic-lease:<provider>issues a replacement dynamic lease forkey(the role), delivers the one-time credential totarget/remote_key, then revokes the old lease named inold_ref— the response never returns the credential.
PostgreSQL, MySQL, and AWS IAM rotators ship as concrete, infrastructure-verified backends covering the full stage/cutover/verify/retire-and-rollback path.
Ephemeral API keys (F38)
For high-churn automation, trstctl issues short-lived API keys through the served control plane:
POST /api/v1/ephemeral/api-keysmints a tenant API token withsubject,scopes, andttl_seconds.trstctl-cli ephemeral api-keys issue -f body.jsondrives the same route.- Guarded by
access:writeplusIdempotency-Key, so a retry returns the original response instead of minting twice. - 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.
{
"subject": "ci-preview-deploy",
"scopes": ["access:read"],
"ttl_seconds": 900
}
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: 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, and in-memory keyrings die on shutdown. Events —
transit.key.created, transit.key.rotated, transit.encrypt, transit.rewrap,
transit.hmac, transit.sign — give audit evidence without logging key bytes or
plaintext.
cat > transit-key.json <<'JSON'
{"name":"payments","kind":"aead"}
JSON
trstctl-cli --idempotency-key transit-payments-create transit keys create -f transit-key.json
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
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.
Secret sync (F68)
trstctl pushes secrets outward via the durable outbox (journaled first, at-least-once,
no half-writes). POST /api/v1/secrets/syncs reads a stored secret, 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.
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. Credential bytes are never logged (wipeable memory, never a copyable string); every attempt is an immutable event in the tamper-evident log.
POST /api/v1/secrets/login serves six 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. 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
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"]
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. POST /api/v1/secrets/scans 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 --idempotency-key ci-secret-scan-1 secrets scans run -f 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.
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 creates one-time, self-destructing shares with durable server-side
state: POST /api/v1/secrets/shares returns the bearer token once, while PostgreSQL
stores only SHA-256(token) plus the envelope-encrypted value in secret_shares — a
valid share survives a restart, and a stolen backup holds neither token nor plaintext.
POST /api/v1/secrets/shares/redeem deletes the row and returns the value exactly
once; a second redeem, expired token, or wrong tenant gets a normal 404.
Change approvals 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 store as a secrets workspace at /secrets: a folder tree,
a ${secret.path} reference resolver, an environment diff between environments or
versions, a version-history selector, bulk secret import, and a transit
sub-console for encrypt/decrypt/HMAC. 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
trstctl-cli --idempotency-key sync-db-password-1 secrets syncs run -f secret-sync.json
curl -fsS -H "Authorization: Bearer $TRSTCTL_TOKEN" \
"$TRSTCTL_URL/api/v1/secrets/syncs/targets"
Pitfalls & limits
- Serving status: rotation ships four variants — rollback-safe static,
connector-backed, dynamic-lease handoff, scheduled dual-phase. 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. - 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.dynamic_cutover,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