Threat model¶
notenv protects secrets with client-side encryption on storage it treats as dumb. This page is the full account of what that defends and what it does not. It is ordered for progressive disclosure: the short version and "what applies to you" cover almost everyone; the rest is depth for reviewers and complex setups. For how the encryption, header, and storage actually work, see How it works.
If this document and the code disagree, treat it as a bug in whichever is wrong and please report it.
The short version¶
Your secrets are encrypted on your machine with a key only you hold, derived from your passphrase. Storage (a local folder or a cloud remote) only ever sees ciphertext. Plaintext is never written to disk; it exists only inside the process you run, only while it runs.
For a solo developer or a small team, that leaves two risks worth your attention:
- A weak passphrase. It is the root of trust. Anyone who copies your storage can guess at it offline, so make it strong (notenv offers to generate one).
- Losing your key. The passphrase lives in your password manager, not on the storage; lose it and the secrets are not recoverable, by design.
Everything below is detail. Almost all of the edge cases apply only to several machines writing a shared remote at once; a solo developer on one machine meets none of them.
What applies to you¶
notenv's risk surface scales with how you use it. Find your row.
A solo developer, one machine¶
A small envelope: a strong passphrase, and a backup of the vault so a dead disk does not lose it. There is no concurrency to race, no second machine to reconcile, no machine credential to leak. The properties below hold; the edge cases at the end do not arise.
A small team, or several machines on a shared remote¶
Add three things:
- Use a read-after-write-consistent remote. Every major provider (Backblaze B2, S3, and the like) is one. Concurrent writers rely on it to serialize safely.
- Offboarding ends at the storage credential.
notenv credential deletere-keys the vault so a removed teammate cannot read new writes, but someone who kept both the old key and write access to the storage could still fork its history. Rotate the storage credential at your provider to close that; notenv reminds you but cannot do it for you. - A machine's first contact with a vault is trust-on-first-use; a collaborator's
is not.
credential add <name>prints an onboarding string (a one-time passphrase plus a vault fingerprint) that verifies the served vault on first contact, so onboarding a teammate is not trust-on-first-use. A machine unlocking byNOTENV_IDENTITYcarries no such fingerprint, so its first contact has nothing to verify against.
Running agents (or any code you hand secrets to)¶
notenv runlets a process use a secret without the value entering what an agent reads, and captured output is masked. This is accident-proofing, not a cage: code running as your user can still extract a value on purpose (see Non-goals).notenv handoffbounds an agent to one namespace. It runs the agent against an ephemeral vault holding only the namespace you scoped in, under a fresh key, so your master key is never in its reach: a compromised or prompt-injected agent can leak that one namespace but can never decrypt the rest of your vault. It bounds which keys the agent holds, not what it does with them (it can still extract what it was handed), and it needs a passphrase-gated source vault. See Agent handoff.- Read equals write. With a single master key, anyone who can decrypt can also
author valid writes, so
read_onlyis a guardrail for cooperating clients; enforced read-only is a read-only storage credential.
What notenv assumes¶
The operating envelope. When these hold, the properties in the next section hold.
- Your passphrase is strong and your password manager keeps it. It is the root of trust; notenv cannot defend a guessable one.
- You do not lose your only credential. There is no recovery backdoor, by design.
- The machine running notenv is yours. A live host with your unlocked key can decrypt; notenv shrinks the exposure but cannot defend a compromised machine.
- Concurrent writers share a read-after-write-consistent remote. Single-machine use and the local backend need nothing here.
- Real revocation and real read-only come from the storage credential, which
notenv does not own. Its own re-key and
read_onlyare the cooperating-client half.
What it protects¶
Three assets, in priority: the secret values, the master key that encrypts them, and the credentials that unwrap it. What holds, and against whom:
Confidentiality of secret values¶
- Against the storage provider or anyone who can read your storage (a leaked
bucket, a stolen read-only credential, a subpoena): they get ciphertext and a
header that wraps the key to slots, never a plaintext key.
- Against a network adversary: the payload is already ciphertext, so notenv
does not depend on the transport's TLS.
- Against a lost or stolen disk with no live session: no plaintext is on disk.
On Linux the caches are RAM-only; on macOS and Windows the only at-rest cache is
the master key, held as ciphertext under your login credentials. A powered-off
image yields ciphertext, useless without the key in your password manager.
Integrity¶
- Against someone who can write your storage but holds no key: they cannot
forge or silently alter the authenticated header, roll it back to an older
revision, delete it, or swap in a different vault without detection. Every stored
blob is bound to the header by a keyed MAC, so deleting, reverting, replaying, or
copying a blob into another namespace alarms with the blob named, and reads
fail closed on anything they cannot verify. They cannot forge a value (it
would not decrypt under the master). A bad write is at worst denial-of-service,
recoverable from the one-generation backup, never silent data loss.
- Master re-keys prove themselves. Each rotation is signed by the outgoing
master, so other machines follow a legitimate re-key silently and only an
unauthorized master change raises the alarm worth a human's attention. (An
ex-holder can still sign a fork; see offboarding above.)
- Against a malicious cloned repository: a committed
notenv.tomlcannot pick your storage (that is machine-local), and the namespace it names is pinned on first use, so a clone cannot silently pointnotenv runat another project's secrets. Headless, an unrecognized namespace is refused unless the runner's own environment names it.
Captured output¶
notenv runmasks the values it injected out of captured stdout/stderr (a pipe, a file, an agent's context), so a process that prints its connection string does not leak it into a log or a model's context. It is accident-proofing, not a boundary: it matches the value and its common encodings, but a transform it does not anticipate walks around it, and values under 6 bytes pass through. Turning it off for a captured stream (--no-mask) takes a freshly typed passphrase, so it is a human's act.(qualified)
No residue¶
- When a
notenv runexits, the plaintext (which lived only in the child's environment) is gone. On Linux the RAM caches clear on logout; on macOS and Windows the cached master key is ciphertext under your login, expiring lazily. "Nothing is left to discover later" holds everywhere; "nothing at all is cached" is the Linux-only stronger form.(qualified on macOS/Windows)
Credentials: people hold passphrases, machines hold identities¶
A vault concentrates risk on purpose: one credential opens every namespace, where
scattered .env files leaked one project at a time. That trade is worth it only if
the credential is harder to steal than the files were, so notenv keeps no
key-equivalent file at rest. A passphrase lives in a person's head and password
manager (scrypt-stretched on every unlock); a machine identity lives in the
platform secret store and is presented per run via NOTENV_IDENTITY. An
infostealer, a copied backup, or an imaged disk finds nothing notenv put there.
Two residuals come with concentration: the offline brute-force surface against
a passphrase slot (why passphrase strength matters), and the brief onboarding
window where a teammate's one-time passphrase is in transit (the slot stays
provisional and refuses to proceed until they replace it; a suspected interception
is cured by credential rotate-master).
Adversaries at a glance¶
| Adversary | Confidentiality | Integrity | Notes |
|---|---|---|---|
| Storage provider / read access | Holds (ciphertext only) | n/a | Metadata is visible (a non-goal) |
| Network MITM | Holds | Holds | Payload is ciphertext regardless |
| Storage write access, no key | Holds | Holds | Tamper alarms; DoS and history forks detected, not prevented |
| Former key holder | Lost for past secrets | n/a | Re-key + rotate the storage credential |
| Lost disk, no live session | Holds | Holds | Nothing secret on disk |
| Live machine + cached key | Lost | n/a | A compromised host is a non-goal |
| Captured child output | Holds for accidents | n/a | Deliberate extraction is a non-goal |
| Malicious build / supply chain | n/a | n/a | Reproducible, signed releases |
Non-goals¶
notenv does not defend these, by design. Calling them out keeps the line honest.
- Metadata. Read access reveals which namespaces exist, roughly how many secrets and their sizes, and when writes happened. Only values are confidential.
- A compromised live machine that holds your unlocked key.
- Code you choose to run.
notenv runhands the child the namespace's secrets; that is the product. Pinning stops a silent cross-project reach, not misuse of what you knowingly hand over. - Deliberate extraction by code running as you. An agent can
printenv KEY | revaround the masker or read the secrets it was handed; masking catches accidents, not intent (the ssh-agent trust model). What notenv contains is the blast radius, not the intent:NOTENV_IDENTITYis stripped from child environments, andnotenv handoffscopes an agent to a single ephemeral namespace with your master key out of reach (see Agent handoff). The agent can still extract that one namespace; notenv bounds what an agent can reach, never what it does with what it holds. - Read-only as containment.
read_onlyandNOTENV_READONLYstop a cooperating client's accidental writes, not an adversary: read equals write under a single master key. Enforced read-only is the storage credential; cryptographic read-only identities are v2 (a decrypt / sign split). - Egress by a process holding a secret. A child handed
$KEYcan send it anywhere it reaches; that is sandbox and network-policy territory. - Availability. Write or delete access can destroy objects (denial-of-service); a versioned remote recovers prior bytes, but notenv does not guarantee uptime.
- A weak passphrase, the credential stores themselves (your password manager, the platform secret store), traffic analysis beyond the metadata above, and un-sharing a value someone already decrypted (re-key prevents future reads; rotate the underlying secret to change the current value).
Edge cases and recovery¶
These are real, documented gaps. Almost all require multiple machines or a
remote; a solo developer on one machine meets none. notenv doctor detects the
recoverable ones and names the fix; the recovery guide
walks through them.
- A machine's first use is trust-on-first-use; a collaborator onboarded with the
onboarding string is not. The string
credential add <name>prints (a one-time passphrase plus a vault fingerprint) verifies the served header against that fingerprint, refusing a substituted vault before the first pin. A machine unlocking by identity has no fingerprint: with no prior revision to compare, a substitution predating its first contact cannot be caught. After first contact, rollback and substitution are detected for both. - Warm-cache runs defer the rollback check by at most one cache TTL: with the key cached, a run does not re-read the header (writes always do). The cached blob carries a MAC checked before use, so a tampered cache entry is rejected.
- A former holder with write access can fork history. The fundamental limit of dumb storage, and why offboarding ends with rotating the storage credential.
- Concurrent header writes need a read-after-write-consistent remote. rclone
has no atomic conditional write, so the swap is read-compare-write-readback;
every major provider meets the requirement. In a rare same-instant race a losing
write can be dropped (recover with
credential restore-backupor by re-running); the local file-lock backend and single-machine use are unaffected. - An interrupted remote write may be unconfirmed. notenv keeps the object
rather than risk deleting one the header now references;
credential restore-backuprecovers if a later read fails. A write that crashed earlier just leaves an unreferenced blob the next write reclaims. - The first-use "expose these secrets?" prompt reads an unauthenticated header, so a storage attacker could suppress it. It is an advisory prompt, not a boundary: the real read still verifies everything and fails closed.
notenv editcan touch persistent disk whenXDG_RUNTIME_DIRis unset (it falls back to the OS temp dir and warns): only values you type that session, in a 0600 file removed on exit.- rclone setup passes the storage credential via argv on the convenience path
(briefly visible to same-user processes); these guard ciphertext storage, not the
vault. Create the remote with
rclone configyourself to avoid it. - Primary-slot governance is advisory: every team slot holds the master, so "who may remove slots" is tooling-enforced, not cryptographic.
Supply chain¶
Releases are built reproducibly with GoReleaser, signed with cosign (keyless), and carry SLSA build provenance; the installation page shows how to verify a download. The release pipeline is pinned to match: every GitHub Action it runs is fixed to an immutable commit SHA, the GoReleaser build is pinned to an exact release, and publishing is gated on a protected environment, so a pushed tag cannot ship a release on its own. The client-side-crypto core is intentionally small and auditable: the tool never needs to be trusted with anything at rest.
Reporting¶
Found a discrepancy or a vulnerability? See Reporting a vulnerability.