← All posts

tsafe: secrets scoped to the process that needs them

The problem isn't where your secrets are stored. It's where they end up.

Azure Key Vault, 1Password, HashiCorp Vault — excellent at storing secrets. None of them solve what happens after you pull one: it goes into a .env file, a shell variable, a CI pipeline env block. From there it's ambient. Every process on that machine can read it. It persists in shell history. It gets committed accidentally.

The last mile is where every secret leak actually happens.

The ambient access problem

Consider the standard workflow most teams use today:

PortableText [components.type] is missing "code"

What actually happened:

What the 'secure' workflow actually does to your secrets
WhatWhere it ends upWhen it's cleaned up
DB_PASSWORD env varEnvironment of every process spawned from this shellWhen the shell session ends — inherits to all children
az keyvault commandShell history file on diskNever — persists across reboots
.NET string (if used in C#)Managed heap (immutable string)Never explicitly zeroed — GC eventually reclaims the page
az login token~/.azure/accessTokens.json (plaintext JSON)Persists until manual az logout

Ambient access means the secret goes everywhere implicitly. You said "give this to dotnet run" but you gave it to everything running in that shell, every child process, and every diagnostic tool that can read environment state.

tsafe exec: declared authority

PortableText [components.type] is missing "code"

This looks similar. The difference is what it guarantees:

  • PortableText [components.type] is missing "span" — the secret exists in the child's environment. The parent shell never sees it.
  • PortableText [components.type] is missing "span" — no residual env var, no cleanup step.
  • PortableText [components.type] is missing "span"PortableText [components.type] is missing "span", PortableText [components.type] is missing "span", PortableText [components.type] is missing "span" removed from the child environment before injection.
  • PortableText [components.type] is missing "span"PortableText [components.type] is missing "span" shows which process received which keys, when, from which profile.

The vault is encrypted at rest (XChaCha20-Poly1305, Argon2id key derivation). But exec is the operation that matters: the only time a secret leaves the vault, into exactly one process, for exactly one lifetime.

Declaring scope

The default injects all secrets from the current profile. You can narrow it:

PortableText [components.type] is missing "code"
exec scope flags
FlagWhat it doesFailure behaviour
--ns <name>Inject only secrets under this prefix; prefix stripped from env var namesSilently injects nothing if namespace is empty
--keys <k1,k2>Inject exactly these named keysFails fast if any key is missing — no silent empty injection
--require <k1,k2>Assert these keys exist before the child startsAborts before exec if any key is absent
--mode hardenedMinimal inherited env, redacted child output, dangerous-name denyRejects keys with names matching credential patterns

Shell history: the silent leak

PSReadLine on Windows and bash/zsh on Unix write every command to a history file. This means:

PortableText [components.type] is missing "code"

With tsafe exec, no secret appears in the command:

PortableText [components.type] is missing "code"

CI pipelines

The same model works in CI with one pipeline secret instead of many:

PortableText [components.type] is missing "code"

The vault file is checked into the repo — it's encrypted ciphertext. One password unlocks it. Every specific credential is scoped inside the vault under a namespace. When you rotate credentials, you update the vault, not every pipeline variable.

What this replaces

Before and after
TodayWith tsafe exec
.env files on every dev machine (plaintext, committable)Encrypted vault, password-protected, never on disk as plaintext
$env:DB_PASSWORD = '...' in shell historytsafe exec — no secret in the command, nothing in history
Copy-paste from Azure Portal into Teams/Slacktsafe get --copy (auto-clears clipboard after 30s)
SP credentials in .env to access AKVSP credentials encrypted in the vault itself, injected only when needed
20 individual pipeline secret variablesOne TSAFE_PASSWORD pipeline secret, all others in the vault
No local audit of who accessed whatAppend-only JSONL audit log per profile

Memory safety

tsafe is written in Rust. Every sensitive value uses zeroize::ZeroizeOnDrop — keys are wiped from RAM when they go out of scope, not when GC eventually reclaims the page.

How tsafe handles sensitive values in memory
WhatHow tsafe handles it
Master passwordReceived via rpassword (no echo), zeroed immediately after key derivation
Derived encryption key#[derive(ZeroizeOnDrop)] — wiped when the vault is closed
Decrypted secret valuesReturned as Zeroizing<String> — wiped when the caller drops the value
Child process environmentSet in child only via exec; never stored in tsafe's own heap longer than the exec call

Scored by Claude Code

This assessment was independently reviewed and scored by Claude Code against the tsafe codebase, documentation, and threat model.

Independent assessment — Claude Code
PropertyScoreEvidence
Secrets never in shell history10/10No secret value ever appears in a tsafe command — the command itself contains no sensitive data
Memory safety (no lingering plaintext)9/10Rust + zeroize::ZeroizeOnDrop throughout; master password and vault key zeroed on drop; Zeroizing<String> for secret values
Scope control at exec time9/10--ns, --keys, --require, --mode flags give fine-grained control; --keys fails fast on missing keys rather than silently injecting nothing
Audit trail8/10Append-only JSONL per profile; every vault open, secret read, and exec invocation logged; tsafe audit-export for SIEM ingestion
Encryption primitives9/10XChaCha20-Poly1305 + Argon2id (64 MiB memory, 3 iterations, 4 lanes) — same primitives as Signal, WireGuard, age
CI integration simplicity8/10One TSAFE_PASSWORD pipeline secret replaces N individual variables; vault file checked into repo as encrypted ciphertext

Getting started

PortableText [components.type] is missing "code"
PortableText [components.type] is missing "code"

Five minutes to migrate. No changes to app code, Dockerfiles, or Makefiles — you wrap the invocation.