Skip to main content

🔐 Envy

Git for your .env files — a zero-knowledge, end-to-end encrypted secret manager with a CLI, a web dashboard, a native VS Code extension, and a local offline UI.

PyPI version Python Version License: MIT Downloads

InstallQuick StartConceptsCryptographyCommandsAPIWhyChangelog

Live: envy.krishnajain.codes · Package: pypi.org/project/envy-secrets


⚠️ Security notice — read if you used envy cloud register

v2.3.0 fixes a critical bug. Any account created via envy cloud register (the CLI's own registration command — not the web dashboard signup) had its real E2EE private key uploaded to the server in cleartext, in the field meant to hold only the public key. That field is readable by any other authenticated user.

If this applies to you: pip install -U envy-secrets, re-register the affected account, then remove and re-add it on every project it belongs to (removal now automatically rotates the project key for everyone remaining). Accounts created through the web signup flow were never affected. Full detail in CHANGELOG.md.


Table of Contents

Understanding Envy

How It Works

Using Envy

Reference


The Problem

Every team hits the same wall with environment variables, and every existing solution trades one problem for another.

Plaintext .env files. They're the default because they're simple. They're also unencrypted on disk, trivially leaked into a git add ., impossible to audit, and shared over Slack in a way nobody feels good about. When someone leaves the team, you have no idea what they still have a copy of.

Committing an .env.example. Solves discoverability, solves nothing about actual secret distribution. New hires still ping someone on Slack for real values.

Third-party secret managers (Vault, Doppler, AWS Secrets Manager). These work, but they all require you to trust the provider's servers with your plaintext. Their servers can read your secrets — the guarantee is a policy promise plus access controls, not mathematics. Several also require meaningful DevOps investment before you get value.

Password managers with "developer" features. Copy-paste workflows that don't integrate with npm start.

The gap: there was no tool that (a) works like Git so it needs no new mental model, (b) is genuinely zero-knowledge so the server operator can't read your secrets even if compelled, and (c) costs a pip install to adopt.

Envy is that tool.


What Envy Actually Is

Envy is a CLI-first secret manager with an optional zero-knowledge sync backend, plus three additional interfaces (web dashboard, VS Code extension, offline local UI) that are all views onto the same encrypted data.

The Git mental model

Envy deliberately borrows Git's vocabulary because the workflows genuinely map:

Git Envy What it does
git init envy init Create a local encrypted store in this directory
git add / git commit envy set KEY=value Record a change locally
git remote add origin envy cloud remote add <slug> Link this directory to a cloud project
git push envy cloud push Upload local state to the remote
git pull envy cloud pull Merge remote state into local
git clone envy cloud clone <slug> Bootstrap a directory from a remote
git diff envy diff / envy cloud diff Compare two profiles, or local vs remote
git blame envy blame KEY Who changed this, when, and from where
git log envy cloud activity / envy cloud history Recent change feed / restorable versions
git revert envy cloud rollback --to <version> Restore a previous state as a new version
Branches Profiles (dev, staging, prod) Parallel sets of values

If you know Git, you already know Envy. That is the entire point of the interface design — see Engineering Decisions.

The one-sentence security claim

Every secret is encrypted on your machine, with a key derived from material the server never receives, before it ever touches the network. Envy Cloud stores ciphertext it is mathematically incapable of decrypting.

The rest of this document explains exactly how that works, exactly what it does not protect against, and exactly why each cryptographic choice was made.


Installation

pip install envy-secrets

Requirements: Python 3.10 or newer. No other runtime needed — the CLI, the crypto, and the local dashboard are all self-contained.

Verify:

envy version    # → Envy v2.4.0

Optional companions

Component Install Needed for
VS Code extension Search "Envy — Secrets Manager" in the Marketplace, or code --install-extension KrishnaJain.envy-vscode Sync state and secrets in the editor sidebar
Web dashboard Nothing — hosted at envy.krishnajain.codes Team management, visual editing, activity graph
Local dashboard Bundled — run envy ui Offline visual management, zero external dependencies

Development install

git clone https://github.com/KRISHNA-JAIN15/ENVY.git
cd ENVY

python -m venv venv
source venv/bin/activate        # Linux/macOS
.\venv\Scripts\Activate         # Windows

pip install -e ".[dev]"

Quick Start

Solo, entirely offline (60 seconds, no account)

cd my-project
envy init                                          # creates .envy/, generates key, updates .gitignore

envy set DATABASE_URL=postgres://localhost/mydb    # encrypted immediately
envy set API_KEY=sk-test-123 --expires 30d         # with a rotation reminder
envy set PORT=3000 --profile prod                  # into a different profile

envy view                                          # masked table of the active profile
envy view --show                                   # reveal values

envy run dev -- npm start                          # inject into the process, never to disk

At this point you have an encrypted vault, three profiles (dev, staging, prod), and a .gitignore that protects you from your own muscle memory. No account, no network, no cloud. Envy is fully functional as a purely local tool.

Adding a team (cloud sync)

envy cloud login                                   # opens browser → passkey or password
envy cloud remote add my-project                   # link this directory to a cloud project
envy cloud push                                    # upload — encrypted before it leaves

# On a teammate's machine, after you add them in the web dashboard:
envy cloud clone my-project --env development
envy run dev -- npm start

System Architecture

Envy is four independent surfaces sharing one cryptographic core and one API.

┌──────────────────────────────────────────────────────────────────────────┐
│                            YOUR MACHINE                                  │
│                                                                          │
│  ┌────────────────┐  ┌─────────────────┐  ┌──────────────────────────┐   │
│  │   Envy CLI     │  │  VS Code Ext    │  │  Local Dashboard         │   │
│  │   (Python)     │◄─┤  (TypeScript)   │  │  envy ui — localhost     │   │
│  │                │  │  shells out to  │  │  Python http.server      │   │
│  │                │  │  the CLI        │  │  no external deps        │   │
│  └───────┬────────┘  └─────────────────┘  └──────────────────────────┘   │
│          │                                                               │
│          │  ┌──────────────────────────────────────────────────────┐     │
│          └─►│  .envy/master.key    AES-256-GCM key (+ OS keyring)  │     │
│             │  .envy/secrets.json  per-secret ciphertext           │     │
│             │  ~/.envy_cloud.json  session token (0600, atomic)    │     │
│             │  OS keyring          E2EE X25519 keypair             │     │
│             └──────────────────────────────────────────────────────┘     │
└───────────────────────────────┬──────────────────────────────────────────┘
                                │  HTTPS — ciphertext only
                                │  (server never receives a plaintext secret,
                                │   a master password, or a private key)
                                ▼
┌──────────────────────────────────────────────────────────────────────────┐
│                        ENVY CLOUD (Render)                               │
│                                                                          │
│  Express 5 API  ──  MongoDB (Mongoose)                                   │
│                     ├─ User      SRP verifier, public key,               │
│                     │            wrapped private key, passkeys           │
│                     ├─ Project   encryptedSecrets (opaque blobs),        │
│                     │            userKeys (wrapped project keys),        │
│                     │            team + RBAC, environments               │
│                     ├─ Activity  audit trail (keys + verbs, not values)  │
│                     └─ Chat      assistant sessions + rate limits        │
└───────────────────────────────┬──────────────────────────────────────────┘
                                │
                                ▼
┌──────────────────────────────────────────────────────────────────────────┐
│                  WEB DASHBOARD (React + Vite, Vercel)                    │
│  Decrypts in-browser via Web Crypto API + libsodium.                     │
│  Private key held in memory + sessionStorage, never sent to the server.  │
└──────────────────────────────────────────────────────────────────────────┘

Why four surfaces?

Each exists because a specific workflow was badly served by the others:

  • CLI — the primary interface. Scriptable, CI-friendly, works over SSH. Everything else is optional.
  • VS Code extension — developers live in the editor. Context-switching to a terminal to check whether a secret is pushed is friction that causes people to skip it. The extension surfaces sync state passively in its sidebar and the status bar.
  • Web dashboard — team management (inviting members, assigning roles, rotating keys) genuinely benefits from a visual interface, and onboarding a non-CLI-comfortable teammate shouldn't require a terminal.
  • Local dashboard (envy ui) — managing 100+ variables in a terminal table is painful, and this works with no account and no network. It's a pure-Python http.server with zero external dependencies precisely so it stays available in air-gapped and offline contexts.

Core Concepts

1. The vault (.envy/)

envy init creates a .envy/ directory in your project:

.envy/
├── master.key      # 32-byte AES-256-GCM key. NEVER commit.
└── secrets.json    # Encrypted secret values + metadata + inheritance + remote

The master key is also mirrored into your OS keyring (Windows Credential Manager, macOS Keychain, Linux Secret Service) keyed by a project identifier, so the file is a backup rather than the sole copy.

envy init automatically appends to .gitignore:

# Envy - Secret Management
**/.envy/master.key
**/.envy/age.key
**/.envy/secrets.json
.env
.env.*
!.env.example

Note on secrets.json: it contains only AES-256-GCM ciphertext, so committing it is cryptographically safe — an attacker with the file and without the key cannot decrypt it. Despite that, envy init gitignores it by default, deliberately. Reasoning: committing ciphertext means a future key compromise retroactively exposes your entire secret history through the Git log, and it puts a permanent copy in every fork and clone. The conservative default is to not create that liability. If your workflow genuinely wants a committed encrypted vault (some teams do — it makes the repo self-contained), remove that line from .gitignore yourself. That is a supported choice, just not the default.

2. Profiles

Profiles are Envy's equivalent of branches — parallel sets of values for the same keys. envy init creates three: dev, staging, prod. One is "active" at a time (envy profile switch), and most commands default to it while accepting --profile/-p to override.

Profiles are just top-level keys in secrets.json, so creating one is free and you can have as many as you like (envy profile create qa, envy profile create load-test).

3. Profile inheritance

The killer feature for real-world config. If staging and prod share 49 of 50 variables, duplicating them is a maintenance disaster — every rotation has to happen twice, and drift is inevitable.

envy profile create staging --extends prod
envy set DATABASE_URL=postgres://staging-db --profile staging

staging now resolves all 50 keys, but stores exactly one. envy view --profile staging shows a Source column distinguishing own from ← prod.

Implementation (storage.py::resolve_profile_secrets): walks the inheritance chain upward collecting profile names into a list, tracking a visited set. It then reverses the chain and applies each profile's secrets in order root→leaf, so children naturally override parents via dict.update(). The visited set means a circular chain (A extends B, B extends A) terminates cleanly instead of looping forever — it resolves with whatever it collected before hitting the cycle, rather than crashing.

Inheritance chains can be arbitrarily deep (prod ← staging ← qa ← local).

4. Local profiles vs. remote environments

A naming impedance mismatch exists and Envy translates it explicitly:

Local profile Remote environment
dev development
staging staging
prod production

The CLI keeps short names because you type them constantly (envy run prod -- ...); the cloud uses long names because they appear in a UI where clarity beats brevity. env_map in main.py translates in both directions on every sync operation. Any profile name not in the map passes through unchanged, so custom profiles (qa) sync as qa.

5. The remote

envy cloud remote add <slug> writes a remote object into secrets.json (origin slug, API URL, timestamp) — directly analogous to git remote add origin. It's what makes push/pull/blame/activity know where to talk to.

6. Clone vs. remote+pull

Both get secrets from the cloud, but they're not interchangeable:

envy cloud clone <slug> --env <environment> — one-time bootstrap.

  • Initializes Envy locally if needed
  • Pulls a single environment into the corresponding local profile
  • Does not establish a persistent link by default
  • Best for: first-time setup of one environment

envy cloud remote add <slug> + envy cloud pull — ongoing sync.

  • Requires Envy already initialized
  • Establishes a persistent remote link
  • pull syncs all profiles by default (--profile narrows it)
  • Enables push, blame, activity
  • Best for: day-to-day team work

Both perform a merge, never a wipe: remote keys are added, conflicting keys take the remote value, and local-only keys are left untouched.

7. Secret metadata

Every secret can carry metadata stored alongside (not inside) the ciphertext:

Field Set by Used for
created_at / updated_at automatic staleness detection
expires_at --expires 30d expiry warnings, envy check
description --desc "..." documentation in view
created_by automatic attribution

--expires accepts 30d, 2w, 6m, 1y, or a bare number (days). A secret is stale if updated_at is more than 90 days old.


The Cryptographic Design

This is the heart of the project. Read this section if you want to know whether to trust it.

Threat model — what Envy defends against

Threat Defended? How
Someone reads your repo / .env from disk Values are AES-256-GCM ciphertext at rest
Accidental git commit of secrets .gitignore written at init; pre-commit hook
Envy Cloud operator reads your secrets Server only ever receives ciphertext; keys are wrapped client-side
Envy Cloud database is dumped/breached Attacker gets blobs + wrapped keys, neither usable without a user's password/passkey
Network attacker (MITM) TLS + payload is already encrypted before transmission
Phishing site harvests your password WebAuthn is origin-bound; SRP never transmits the password
Another local user reads your session token ~/.envy_cloud.json is 0600, written atomically
Departed teammate keeps decrypting Removal triggers project key rotation (v2.3.0+)
A staging blob replayed as production AAD binds ciphertext to project + environment (v2.3.0+)
Brute-forcing your master password online Rate limiting on all verification endpoints
Secrets leaking into your app's own logs ⚠️ Partial Best-effort masking — see Log Masking

What Envy does not defend against

Being explicit here matters more than marketing:

  • A compromised client machine. If malware runs as you, it can read your keyring, your secrets.json, and your decrypted process environment. No client-side E2EE system survives this.
  • A malicious server substituting public keys. When you add a teammate, your client fetches their public key from the server. A malicious server could return its own key and receive a decryptable copy of your project key. Envy mitigates this with public key fingerprints (shown in the UI, verifiable out-of-band) but cannot eliminate it — this is the inherent trust boundary of any server-mediated key directory, including Signal's.
  • Forgetting your master password with no recovery set up. The server has no copy of anything that can decrypt your vault, so it cannot help you. Recovery codes and Shamir shares address this — but only if you generated them beforehand, and they shift the problem rather than removing it: a recovery code is another secret you now have to keep safe.
  • XSS in the web dashboard. Your private key lives in sessionStorage while you're logged in (it must — the crypto runs in the page). A successful XSS reads it. Mitigated by a strict CSP (script-src 'self'), not eliminated.
  • Malicious/compromised teammates. Anyone with legitimate decrypt access can exfiltrate what they can read. RBAC limits scope; it can't prevent authorized reads.

The key hierarchy

Envy uses four distinct key types. Understanding the hierarchy is understanding the system.

   Master Password (never leaves your device, never stored anywhere)
        │
        │  PBKDF2-HMAC-SHA256, 600,000 iterations, 16-byte random salt
        ▼
   Account Key (32 bytes, derived on demand, never persisted)
        │
        │  AES-256-GCM
        ▼
   E2EE Private Key ──── the server stores ONLY this encrypted form
   (X25519, 32 bytes)    (plus the matching public key in cleartext)
        │
        │  libsodium sealed box (crypto_box_seal) — anonymous, authenticated
        ▼
   Project Master Key (32 bytes, AES-256-GCM, one per project)
        │                one sealed copy stored per authorized user
        │
        │  AES-256-GCM with AAD = "envy-e2ee-v1:{slug}:{environment}"
        ▼
   Encrypted Secrets Blob (one per environment, opaque to the server)


   Separately, for purely local storage (no cloud involved):

   Local Master Key (32 bytes, .envy/master.key + OS keyring)
        │
        │  AES-256-GCM, per-secret
        ▼
   Individual encrypted values in secrets.json

Layer 1 — Local encryption at rest

Independent of the cloud. Every value is encrypted individually with the project's local master key:

b64_urlsafe( nonce[12] ‖ AES-256-GCM(value) ‖ tag[16] )

Each envy set generates a fresh random 96-bit nonce (os.urandom(12)). The 16-byte GCM authentication tag means tampering with secrets.json is detected on decrypt, not silently accepted.

Why per-secret rather than one blob for the whole file? Three reasons: you can read one key without decrypting everything; a corrupted entry doesn't destroy the whole vault; and diffs of secrets.json show which entries changed rather than one giant opaque line.

Layer 2 — End-to-end encryption for cloud sync

This is where "zero-knowledge" is earned. Walk through it operation by operation.

Registration

  1. You enter a master password. It never leaves the device.
  2. Client derives an Account Key — PBKDF2-HMAC-SHA256, 600k iterations, random 16-byte salt.
  3. Client generates an X25519 keypair.
  4. Client encrypts the private key with the Account Key (AES-256-GCM), serialized as base64 of the base64-encoded key bytes (a format detail both the CLI and browser agree on exactly).
  5. Client computes an SRP-6a verifier from the password.
  6. Sent to the server: public key (cleartext), encrypted private key, key salt, SRP salt + verifier.
  7. Never sent: the password, the Account Key, the plaintext private key.

The server can verify you know your password (via SRP) and can hand your encrypted private key back to you, but cannot derive the Account Key needed to open it.

Project creation

  1. Client generates a random 256-bit Project Master Key.
  2. Client wraps it for its own public key using a libsodium sealed box (crypto_box_seal).
  3. Sends the wrapped key to the server.

A sealed box generates a throwaway ephemeral keypair per operation, does X25519 ECDH against the recipient's public key, and encrypts with XSalsa20-Poly1305. The sender is anonymous and the ciphertext is authenticated. Only the holder of the recipient private key can open it.

Pushing secrets

  1. Client decrypts local secrets with the local master key.
  2. Client serializes them to JSON and encrypts the whole dict with the Project Master Key using AES-256-GCM, with AAD = envy-e2ee-v1:{project-slug}:{environment}.
  3. Uploads base64(nonce ‖ ciphertext ‖ tag) plus a keyCount integer.

What the server stores: an opaque base64 blob, and a count of how many keys are in it. Not the key names, not the values.

Why the AAD matters (v2.3.0). All three environments share one Project Master Key. Without binding, the ciphertext for production would decrypt perfectly if someone moved it into the development slot — meaning environment separation existed only as a server-side access check, not a cryptographic property. A malicious or compromised server could swap blobs and a developer (who legitimately holds the project key but is not authorized for production) would silently decrypt production secrets. Binding the environment name into the GCM authentication tag makes that swap fail with an authentication error. Decryption falls back to AAD-less once for blobs written before this existed, so no data was orphaned.

Adding a teammate

  1. Admin fetches the teammate's public key from the server (fingerprint displayed for out-of-band verification).
  2. Admin's client unwraps its own copy of the Project Master Key with its private key.
  3. Admin re-wraps that key in a sealed box for the teammate's public key.
  4. Uploads the new wrapped key.

The plaintext Project Master Key exists only in the admin's browser memory, for milliseconds. The server sees two wrapped copies and can open neither.

Removing a teammate — and why rotation is mandatory

Removal does two things:

  1. Revokes — their userKeys entry is deleted, so the API stops handing them the wrapped key.
  2. Rotates — a new Project Master Key is generated, every environment blob is re-encrypted under it, and it is re-wrapped for every remaining member. Atomically, in one write.

Step 2 exists because step 1 alone is theatre. A departed member may already have unwrapped the key and cached it locally. Revoking their ability to fetch it again changes nothing about the copy they hold. Only re-keying does.

Because the server never has the plaintext key, rotation is necessarily client-driven: an admin's browser decrypts everything with the old key, generates a new one, re-encrypts, and submits the complete result. The server validates that the submitted wrapped-key set covers exactly the current membership — no more (smuggling in an unauthorized user), no fewer (silently locking out a current member) — and rejects anything else.

Rotation requires every remaining member to have E2EE keys set up. If one doesn't, the client skips rotation and warns, rather than submitting a partial set that would lock someone out.

Cryptographic primitives — and why each

Layer Algorithm Standard Why this one
Symmetric encryption AES-256-GCM NIST SP 800-38D AEAD — encrypts and authenticates in one pass. Hardware-accelerated on every modern CPU (AES-NI). Same cipher suite as TLS 1.3. Rejects tampered ciphertext instead of returning garbage.
Key exchange X25519 (Curve25519) RFC 7748 Modern ECDH. Smaller keys and faster than RSA at equivalent security. No parameter-choice footguns. Used by Signal, WireGuard, TLS 1.3.
Key wrapping libsodium sealed box NaCl Anonymous + authenticated encryption to a public key. Ephemeral sender keypair per operation means no long-term sender key to compromise.
Password KDF PBKDF2-HMAC-SHA256, 600k iterations NIST SP 800-132 OWASP 2023 recommended count. Chosen over Argon2id for a specific reason — see below.
Passwordless auth WebAuthn / FIDO2 W3C WebAuthn L2 Credentials are cryptographically bound to the origin. Phishing is impossible at the protocol level, not merely discouraged.
Headless auth SRP-6a RFC 5054 Zero-knowledge password proof. Password never crosses the wire even to a hostile server.
Vault unlock (passwordless) WebAuthn PRF + HKDF-SHA256 WebAuthn L3 draft Derives a stable secret from the authenticator itself — biometric unlock of an E2EE vault.
Randomness os.urandom / crypto.getRandomValues NIST SP 800-90A OS CSPRNG. Never a userspace PRNG for keys, nonces, or salts.

Why PBKDF2 and not Argon2id

Argon2id is the better algorithm. Envy used it, and removing it was a bug fix.

The CLI derived the E2EE-private-key wrapping key with Argon2id. The browser — which cannot run Argon2id in the Web Crypto API — used PBKDF2. Neither side implemented a KDF negotiation. Every unwrap path (both envy cloud login and the web app) hardcoded PBKDF2.

Result: a vault created by envy cloud register was wrapped with Argon2id and could never be unwrapped by anything, including the CLI that created it. Silent, permanent, and it presented to users as "wrong password."

The fix was not to teach the browser Argon2id. It was to recognize that one algorithm both clients provably agree on is worth more than a stronger algorithm they disagree about. derive_account_key() is now the single function used by both the wrap side and the unwrap side, so they cannot drift again. Argon2id and the argon2-cffi dependency were removed entirely.

At 600,000 iterations, PBKDF2-HMAC-SHA256 meets current OWASP guidance. Argon2id would be better against GPU-accelerated cracking. Correctness won.

Design lesson worth stating plainly: in cryptographic code, a strength difference between two good algorithms is almost always smaller than the risk of two implementations disagreeing. Prefer the boring shared primitive.

Where each key physically lives

Key Location Persisted?
Master password Your head Never
Account key Process memory, derived on demand Never
E2EE private key OS keyring (CLI) / sessionStorage + memory (browser) Encrypted on server
E2EE public key Server, cleartext Yes — it's public
Project master key Memory during operations Only as sealed-box copies
Local master key .envy/master.key + OS keyring Yes, locally only
Session token ~/.envy_cloud.json (0600) / HttpOnly cookie Yes

Authentication Deep Dive

Envy supports three authentication paths, each for a different environment.

SRP-6a — headless / password login

For SSH sessions, CI, containers — anywhere a browser can't open. RFC 5054.

envy cloud login --headless

Step 1 — Challenge. Client sends email + ephemeral public value A. Server responds with the user's salt and its own ephemeral B.

Step 2 — Proof. Client computes proof M1 from the password locally. Server verifies M1 against the stored verifier and responds with its own proof M2. The client verifies M2 — this is mutual authentication: a fake server that doesn't hold the verifier cannot produce a valid M2.

The server stores only a mathematical verifier, which cannot be reversed into the password. A hostile server learns nothing about your password even across many login attempts, and the client detects the hostile server.

Sessions are held server-side in memory, keyed by email, with a 5-minute expiry and a periodic sweep.

WebAuthn / Passkeys — browser login

envy cloud login    # opens browser

Passkeys are bound to the exact origin. A phishing site at envvy.codes cannot obtain a credential for envy.krishnajain.codes — the browser refuses at the protocol level. There is no user judgment involved, which is what makes it work.

Supported: Apple Touch ID, Windows Hello, 1Password, Google Password Manager, YubiKey, any FIDO2 authenticator.

Origin validation hardening (v2.3.0). expectedOrigin previously fell back to the request's own Origin header when WEBAUTHN_ORIGIN was unset — making the check tautological (attacker_origin === attacker_origin). RP-ID binding still prevented actual credential misuse, but a defense layer was doing nothing. The header is now only trusted if it appears in the server's own CORS allowlist.

WebAuthn PRF — true passwordless E2EE

Authentication proves who you are. Decryption needs a key. Normally that means: passkey to log in, then master password to unlock the vault. Two steps.

The PRF (Pseudo-Random Function) extension collapses them. The authenticator derives a stable, high-entropy secret from its own hardware key material plus a fixed salt (envy-e2ee-vault-v1). Envy runs that output through HKDF-SHA256 to produce an AES wrapping key, and stores a second copy of your private key encrypted under it.

Result: your fingerprint unlocks your E2EE vault. No password typed.

The PRF bootstrap problem (and the two-step solution)

There's a genuine chicken-and-egg here, and Envy handles it explicitly rather than failing:

To store a PRF-encrypted copy of your private key at passkey-registration time, the client needs your decrypted private key already in memory. If you register a passkey in a session where you haven't done a password login, the vault is locked — so PRF wrapping is skipped and prfEncryptedPrivateKey is stored as null.

Later, logging in with that passkey succeeds (authentication works) but returns prfEncryptedPrivateKey: null. A naive implementation would hand the CLI a valid session token with no keys, and every subsequent pull/push would fail with "E2EE keys not found."

Instead, the client detects this and sets needsVaultUnlock, and the login page transitions to a Vault Unlock screen requesting the master password once. Either way — PRF or manual unlock — the decrypted keys are delivered to the CLI's loopback callback and stored in the OS keyring.

PRF support reality check

Not all authenticators implement PRF, and the pattern is counterintuitive:

Authenticator PRF Experience
YubiKey / hardware keys ✅ Excellent True one-step passwordless
Windows Hello / Touch ID (saved to device/TPM) ✅ Generally One-step
Cloud-synced (Google Password Manager, iCloud Keychain) ❌ Usually dropped Always two-step

Cloud managers silently drop the PRF extension because syncing HMAC secrets across devices securely is hard. To get one-step login on Windows, explicitly save the passkey to "Windows Hello" or "This Device" rather than the cloud manager.

The CLI browser handoff (OAuth loopback)

envy cloud login needs a browser for the passkey ceremony, then needs the result back in the terminal. RFC 8252 loopback flow:

  1. CLI generates a random state token (secrets.token_urlsafe(32)).
  2. CLI binds an HTTP server to 127.0.0.1 on an OS-assigned ephemeral port.
  3. CLI opens the browser to the login page with cli_port and state.
  4. You authenticate; the browser POSTs to the API, which returns a one-time loopback redirect URL.
  5. The browser hits http://127.0.0.1:<port>/callback?...&state=....
  6. CLI compares the returned state with hmac.compare_digest — mismatch is rejected and the server keeps waiting.

Both of those hardening details fix real vulnerabilities (v2.3.0):

  • No state previously meant any web page open in your browser during the ~5-minute login window could fire <img src="http://127.0.0.1:54321/callback?token=ATTACKER_JWT"> and make the CLI save the attacker's session. Your next envy cloud push would upload your secrets into their account. This is the exact attack state exists to prevent (RFC 8252 §8.9).
  • A hardcoded port (54321) made that trivially targetable and let a local process squat the port first.

Additionally, the token and E2EE keys now travel to the API as a POST body, not a URL query string, so they don't land in browser history or reverse-proxy access logs. Only the final loopback hop — which never leaves the machine — carries them in a URL, as the RFC pattern requires.

Sudo mode — step-up authentication

High-risk operations require re-authentication within the last 5 minutes, even with a valid session. Guarded operations:

  • Any secret write (env-e2ee push, legacy env write, bulk import, delete)
  • Granting a project key to another user
  • Project key rotation
  • Team add/update/remove
  • Project update / delete

Re-auth is via passkey (userVerification: "required" — a mere possession tap is not step-up) or master password over SRP. Success sets a 5-minute sudo_token.

CLI exemption. The CLI can't do interactive sudo, so X-Envy-Source: cli bypasses it — but only when no cookie is present. Browsers always attach cookies, so a browser-based attacker (including XSS) cannot forge this path; they'd have to send no cookie, which means no authentication at all.

Token revocation

JWTs are stateless, so logout only clears the local cookie — a leaked Bearer token stays valid until expiry (7 days). POST /api/auth/logout-all increments a tokenVersion on the user record; protect compares the token's claim against the stored value and rejects mismatches. One call invalidates every session everywhere. Tokens issued before this field existed are treated as version 0, so nothing breaks until the first revocation.


Access Control Model

Roles

Role Read dev Write dev Read/write staging Read/write production Manage team Delete project
admin
developer
viewer

The project owner is always treated as admin.

The design intent: production credentials are the crown jewels, and most engineers don't need them to do their jobs. developer is the default useful role — full access to the environments people actually iterate in, no access to production.

Important: this boundary is enforced at the API layer. Because all environments share one Project Master Key, a developer who obtains the production ciphertext by some other means could decrypt it. AAD binding prevents blob-swapping attacks, but per-environment keys would be required for true cryptographic separation — see Known Limitations.

Public projects

Setting a project public grants every authenticated user implicit viewer — read access to development only. Intended for OSS template repos where the schema of required variables is the useful artifact.

Public projects are deliberately restricted in what they expose. Since v2.3.0 the activity feed strips ipAddress and variable value previews for anyone below developer, because hasAccess("viewer") is true for any logged-in stranger on a public project — and isSecret defaults to false, meaning non-secret variable values were previously readable from the feed.

Legacy role normalization

An earlier schema used member/owner; the current enum is admin/developer/viewer. Project.normalizeRole() maps old→new on both read and write. This matters because Mongoose validates every subdocument on save — one stale member value in a team array would fail an unrelated update. Any project touched by an admin action self-heals.


Data Formats & Storage

.envy/secrets.json

{
  "active_profile": "dev",
  "profiles": {
    "dev": {
      "DATABASE_URL": "<base64url(nonce‖ciphertext‖tag)>",
      "API_KEY": "<base64url(nonce‖ciphertext‖tag)>"
    },
    "staging": { "DATABASE_URL": "<...>" },
    "prod": {}
  },
  "metadata": {
    "dev": {
      "API_KEY": {
        "created_at": "2026-01-15T10:30:00",
        "updated_at": "2026-01-15T10:30:00",
        "expires_at": "2026-02-14T10:30:00",
        "description": "Stripe test key"
      }
    }
  },
  "inheritance": { "staging": "prod" },
  "remote": {
    "origin": "my-project",
    "api_url": "https://envy-baq3.onrender.com/api",
    "added_at": "2026-01-15T10:00:00"
  }
}

Written atomically (v2.3.0): temp file in the same directory → fsyncchmod 0600os.replace(). A crash mid-write leaves the previous file completely intact. Previously open(path, "w") truncated first, so an interrupted write could destroy the vault with no recovery path.

~/.envy_cloud.json

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "email": "you@example.com",
  "name": "Your Name",
  "api_url": "https://envy-baq3.onrender.com/api"
}

Session token only — no password, no cryptographic keys (those live in the OS keyring). 0600, atomic write, same as above.

Why a fixed global path? os.path.expanduser("~/.envy_cloud.json") deliberately ignores virtualenvs. Install Envy in five different venvs and they all share one identity — log in once, logged in everywhere. Exactly like git.

MongoDB collections

usersname, email (unique), srpSalt/srpVerifier (select: false), publicKey (cleartext), encryptedPrivateKey/keySalt (select: false), passkeys[] (credential ID, public key, counter, optional PRF-wrapped key), tokenVersion, avatar fields.

projectsname, slug (unique, immutable after creation), owner, team[] (user + role), environments[], encryptedSecrets (Map env→blob), envKeyCounts (Map env→count), envVariables (Map, legacy plaintext path), userKeys[] (user + wrapped key), starred[], visibility, tags, githubUrl.

activitiesproject, user, action (enum), environment, variables[] (key + verb + masked preview), message, source (web/cli/api), ipAddress, shortId (7-char Git-style hash), timestamps.

chatsessions / chatratelimits — assistant conversations and per-user daily quota.


Complete Command Reference

Core

Command Flags Description
envy init --force/-f Initialize .envy/, generate the AES-256-GCM key, create dev/staging/prod, update .gitignore. --force resets keys (destroys access to existing secrets).
envy set KEY=VALUE --profile/-p, --expires/-e, --desc/-d Encrypt and store. Validates the key name and any schema rules before writing.
envy get KEY --profile/-p, --show/-s Retrieve one value. Masked unless --show.
envy delete KEY --profile/-p, --force/-f Delete a secret and its metadata.
envy view --profile/-p, --show/-s, --json/-j Table of all secrets including inherited, with Source and Status columns.
envy run <profile> -- <cmd> --mask/--no-mask Inject decrypted secrets into a subprocess environment. Never touches disk.
envy export --profile/-p, --output/-o, --merge/--overwrite, --json/-j Write a plaintext .env.<profile>. Merges by default, preserving unrelated keys.
envy import <file> --profile/-p, --merge/-m Import an existing .env, encrypting every value.
envy capture --profile/-p, --filter/-f, --exclude-system/--include-system Capture the current shell's environment into a profile.
envy diff <source> <target> --show/-s Compare two profiles — missing, extra, and differing keys. Defaults to dev vs prod.
envy check --profile/-p, --json/-j Health check: expired, expiring-soon, stale (>90d). Exit code 1 if issues — CI-friendly.
envy blame <KEY> --limit/-n (10), --json/-j Change history for one key from the cloud. Requires a remote.
envy status --json/-j Project summary: active profile, counts, schema, encryption, remote.
envy shell --profile/-p Print shell export statements. Use as eval $(envy shell).
envy version Print the installed version.
envy ui --port/-p (8888), --no-open Launch the local dashboard.

Profiles

Command Flags Description
envy profile list --json/-j All profiles with counts and inheritance.
envy profile create <name> --extends/-e <parent> Create a profile, optionally inheriting.
envy profile delete <name> --force/-f Delete a profile and all its secrets.
envy profile switch <name> Change the active profile.
envy profile copy <src> <dst> Clone all secrets between profiles.

Schema

envy schema <action> takes show (default), init, or validate, plus --profile/-p and --json/-j.

Command Description
envy schema show Display current envy.schema.json rules.
envy schema init Generate a schema from existing secrets, inferring types from key names.
envy schema validate Validate a profile (active by default, or --profile).

Cloud

Command Flags Description
envy cloud register --email/-e, --name/-n, --api Create an account. Computes SRP verifier + E2EE keypair locally.
envy cloud login --email/-e, --api, --headless Browser passkey flow by default; --headless uses SRP.
envy cloud logout Delete the local session file.
envy cloud status --json/-j Show the logged-in identity.
envy cloud clone <slug> --env/-e, --output/-o, --json/-j Bootstrap from a cloud project (one environment).
envy cloud remote add <slug> / show / remove, --json/-j Manage the persistent remote link.
envy cloud push --profile/-p, --message/-m, --force/-f, --json/-j Encrypt and upload.
envy cloud pull --profile/-p, --force/-f, --json/-j Merge remote into local. Without --force, local-only keys are never deleted.
envy cloud diff --profile/-p, --json/-j Compare local vs remote across all profiles concurrently.
envy cloud activity --limit/-l (20), --json/-j Git-log-style feed for the linked project.
envy cloud history --profile/-p, --limit/-l (20), --json/-j Version history for an environment — what you can roll back to.
envy cloud rollback --to (required), --profile/-p, --message/-m, --yes/-y, --pull/--no-pull, --json/-j Restore a previous version. Appends it as a new version (revert, not reset).

Team & hooks

Command Description
envy team list List members and roles for the linked project.
envy hooks install Install the pre-commit hook.
envy hooks uninstall Remove only Envy's portion, preserving other hooks.

Adding/removing team members is web-dashboard only. Membership changes require wrapping the project key for the new member's public key — a cryptographic operation needing an unlocked vault plus key-fingerprint verification. That belongs in an interface that can show you the fingerprint you're trusting. envy team list is read-only by design.


Feature Deep Dives

envy run — the secure execution path

The recommended way to run anything that needs secrets.

envy run prod -- npm start
envy run dev -- python manage.py migrate

Mechanism: decrypt all resolved secrets (including inherited) into memory, copy os.environ, overlay the secrets, and subprocess.run(command, env=env_vars).

Critical implementation details:

  • os.environ.copy() first. The child gets your real PATH, HOME, SystemRoot — without this, most commands break outright on Windows.
  • No shell=True. The command is a list, passed as argv. A secret containing ; or && cannot become a command.
  • Exit codes propagate. raise typer.Exit(proc.returncode) on both the masked and unmasked paths, so CI correctly fails.
  • Nothing written to disk. No temp .env, no cleanup step that might not run.

Log masking and its honest limits

With masking on (default), Envy pipes the child's stdout/stderr through two daemon threads that replace any raw secret value with ******** before printing.

# App prints:  "Connecting to postgres://admin:s3cret@db:5432"
# You see:     "Connecting to ********"

What it does not catch — and the docstring says so explicitly:

  • Values re-encoded before printing (base64, URL-encoding, JSON escaping)
  • Values split across lines by the child's own buffering
  • Values shorter than 4 characters (intentionally skipped — too many false positives)
  • Values transformed at all (uppercased, truncated, interpolated)

It's exact per-line substring matching. Treat it as a safety net that catches the common accident, not a guarantee. Masking also pipes stdout, so the child no longer sees a TTY — use --no-mask for interactive programs or when you need color output.

Schema validation

Define envy.schema.json and Envy enforces it before writing, blocking the operation on failure.

{
  "variables": {
    "PORT": { "type": "integer", "required": true },
    "DATABASE_URL": { "type": "url", "required": true },
    "STRIPE_KEY": {
      "type": "string",
      "profiles": {
        "dev":  { "pattern": "^sk_test_" },
        "prod": { "pattern": "^sk_live_" }
      }
    },
    "LOG_LEVEL": { "type": "string", "enum": ["debug", "info", "warn", "error"] },
    "APP_NAME":  { "type": "string", "minLength": 3, "maxLength": 50 }
  }
}
Rule Values / behavior
type string, integer, number, boolean (true/false/1/0/yes/no), url (http(s)://), email
required Checked by validate_profile, not by set — a missing key can't be caught when setting a different one
pattern Regex, applied via re.match (anchored at start)
enum Exact membership
minLength / maxLength String length bounds
profiles Per-profile overrides of pattern and enum
$ envy set STRIPE_KEY=sk_test_123 --profile prod
Schema Error: STRIPE_KEY: Value must match pattern '^sk_live_' for profile 'prod'

The profiles override is the feature that earns the whole subsystem: it makes "test key accidentally deployed to production" structurally impossible, which is a genuinely common and genuinely expensive incident.

envy schema init bootstraps a schema from what you already have, inferring integer for *PORT*, url for *URL*/*ENDPOINT*, email for *EMAIL*, and boolean for *ENABLED*/*DEBUG*/*VERBOSE*.

envy blame — audit trail

$ envy blame DATABASE_URL
┌──────────────────────────────────────────────────────────────┐
│                    📜 Blame: DATABASE_URL                    │
├────────┬──────────┬─────────┬──────┬────────┬────────────────┤
│ Commit │ User     │ Action  │ Env  │ Source │ When           │
├────────┼──────────┼─────────┼──────┼────────┼────────────────┤
│ a3f2c1 │ Krishna  │ updated │ prod │ cli    │ 2 hours ago    │
│ 8b1d4e │ Krishna  │ created │ prod │ web    │ 3 days ago     │
└────────┴──────────┴─────────┴──────┴────────┴────────────────┘

Queries the cloud activity log for one key. The Source column distinguishes CLI from web from API. shortId is a 7-character Git-style hash.

Zero-knowledge preserved: the client sends only key names and action verbs alongside an encrypted blob push — never values. The server builds a useful audit trail without ever seeing a secret.

History & rollback

blame answers "who changed this key?". History answers a different question: "what whole states can I return to?" — which is what you actually need at 2am when a push broke production.

$ envy cloud history -p prod
┌──────────────────────────────────────────────────────────────────────────┐
│                             History: production                          │
├─────────┬──────┬─────────┬────────┬─────────────┬───────────────────────┤
│ Version │ Vars │ Who     │ Source │ When        │ Message               │
├─────────┼──────┼─────────┼────────┼─────────────┼───────────────────────┤
│ a3f2c1  │   12 │ Krishna │ cli    │ 2 hours ago │                       │
│ 8b1d4e  │   11 │ Krishna │ web    │ 3 days ago  │ add stripe key        │
│ 41c7f9  │   11 │ Priya   │ cli    │ 6 days ago  │ ⟲ restore of 2e8a07   │
└─────────┴──────┴─────────┴────────┴─────────────┴───────────────────────┘

$ envy cloud rollback --to 8b1d4e -p prod

Every E2EE push writes a version automatically — there's nothing to enable and no extra step at push time, because a history you have to remember to record is a history you don't have when you need it. The last 50 versions per environment are kept.

Rollback appends the recovered state as a new version rather than rewinding the history. This is git revert, not git reset: the state you rolled away from is still listed and can itself be restored, so a panicked rollback at 2am is never the irreversible move. Rollbacks are marked ⟲ restore of <version> so the timeline stays honest about what happened.

Because versions are stored as the same opaque encrypted blobs as everything else, the server cannot read your history — it stores and returns ciphertext, and your client decrypts the version you choose.

The key-rotation interaction — deliberate, not a bug. Removing a team member rotates the project key, which permanently prevents anyone from decrypting versions written before that point, including you. If old versions stayed readable after rotation, a departed teammate who kept a copy of the old key could still read everything from before they left — precisely what rotation exists to prevent. Each version records the key epoch it was written under; pre-rotation versions are listed as (pre-rotation, unreadable) and rollback rejects them with a 409 rather than restoring a blob nobody can open.

Recovery

Zero-knowledge has an unavoidable cost: nobody can reset your password for you. That cost is the single most common reason a team refuses to adopt a product like this, so Envy offers two escape hatches — both of which keep the server unable to read anything.

Recovery codes — for you, when you forget your master password.

Ten one-time codes are generated in your browser, each independently wrapping its own encrypted copy of your E2EE private key. Any one of them recovers your vault. The server stores only ciphertext it cannot open (the wrapping key is derived from the code itself, and the code is displayed exactly once and never transmitted), plus the first 5 of 15 characters as a hint so you can tell codes apart in a password manager.

Codes look like K7M2N-PQR4S-TVW9X: the alphabet excludes 0/O, 1/I/L, and U, so a handwritten code can't be misread into a different valid code — the failure mode that makes paper backups worthless. Using a code burns it and invalidates every existing session, on the reasoning that if you needed recovery, the account's state was uncertain.

Shamir Secret Sharing — for your team, when the person who set the project up is unavailable.

A project's master key is split into n shares distributed to admins, any k of whom can reconstruct it. Shares are handed to people and never uploaded, so the server cannot reconstruct a project even in principle.

Implemented over GF(2⁸) with the AES polynomial. Shamir rather than "encrypt the key separately for each admin" because Shamir is information-theoretic: k−1 shares reveal literally nothing — not a weakened key, not a smaller search space, nothing — whereas n separate copies hand the whole key to whoever compromises any single admin. Each share carries a SHA-256 checksum, so a mistyped share is rejected outright instead of silently reconstructing a wrong key that then fails somewhere far less obvious.

Both mechanisms are opt-in. Neither is generated unless you ask, and a recovery code stored beside the password it recovers protects you against forgetting, not against compromise.

Pre-commit hook

envy hooks install

Solves a specific recurring failure: a developer adds STRIPE_KEY locally, writes the code, pushes to Git — but forgets envy cloud push. CI breaks for everyone, and the cause is non-obvious.

The hook checks whether .envy/secrets.json is staged and warns. On Unix it's interactive — it prompts and aborts the commit unless you confirm (reading from /dev/tty, since Git hooks don't have stdin attached). On Windows it warns without blocking, because the interactive read isn't reliable there.

envy hooks uninstall surgically removes only Envy's block, preserving other hooks — and appends rather than overwrites on install, for the same reason.

envy check — rotation reminders

envy check          # exit 1 if issues → drop straight into CI

Reports expired (past expires_at), expiring soon (≤7 days), and stale (updated_at >90 days). Combined with --expires at set time, this turns rotation from a calendar reminder nobody honors into a build failure.

envy cloud pull and the data-loss fix

pull performs a merge, never a wipe:

  • Remote keys not present locally → added
  • Keys in both → remote value wins
  • Keys only present locally → left completely alone

That last rule matters. --force was previously documented but never checked, so deletion of local-only keys ran unconditionally — silently destroying secrets you'd added but not yet pushed. Now, without --force, local-only keys are preserved and reported as kept_local_only, because "exists locally but not on remote" is indistinguishable from "added locally, not pushed yet."

envy cloud diff — concurrent by design

Compares local vs remote for every profile. With three default profiles each needing two round-trips, sequential fetching cost up to six serial requests — noticeable every time the VS Code Activity Graph refreshed. Now uses a ThreadPoolExecutor sized to the profile count, so total latency is one round-trip rather than six.


The Web Dashboard

Hosted at envy.krishnajain.codes. React 19 + Vite 7, deployed on Vercel.

All decryption happens in your browser via the Web Crypto API and libsodium-wrappers. The server sends ciphertext; the page opens it.

Area Capability
Dashboard Project grid, search, filters (owned/shared/starred), sort, live stat counters
Project detail Per-environment variable management, reveal/copy, filter, bulk copy, .env download
Team Add/remove members, assign roles, public key fingerprints, automatic key rotation on removal
Activity Git-graph-style timeline, contribution heatmap, per-key blame
Settings Rename, visibility, environments, delete (typed confirmation)
Auth Passkey registration/login, SRP fallback, vault unlock, password strength meter
Assistant Claude-powered help chat, 10 messages/day

Key held in sessionStorage so a page refresh doesn't force re-unlock, and cleared on logout. This is a deliberate, documented tradeoff — see Engineering Decisions.


The VS Code Extension

Published as KrishnaJain.envy-vscode. TypeScript, shells out to the envy CLI — it contains no crypto, no auth, and never holds a secret beyond what the CLI already printed.

The Changes view

Local-vs-remote drift lives in a Changes view in Envy's own sidebar, above Secrets and Graph:

  • Local Changes (Unpushed) and Remote Changes (Unpulled) groups
  • Push / Pull / Refresh in the title bar; push prompts for an optional message, wired to envy cloud push --message
  • A badge carrying the pending-change count

Why not the Source Control panel? Envy used to register a real SCM provider via vscode.scm.createSourceControl, which put it next to Git. Two problems made that the wrong home. VS Code lists every registered provider with no way to opt out, and giving the provider the workspace folder as its rootUri — needed to associate it with the project — filed it under the same heading as the Git repository, so it read as part of Git rather than beside it.

More decisively, the Source Control Graph is driven by SourceControl.historyProvider, which remains a proposed API that a Marketplace-published extension cannot implement. Selecting the Envy entry therefore showed Git's commit graph — not a bug in the panel, but the absence of any Envy history for it to render. The entry advertised a capability it structurally could not have, so it was removed rather than left to mislead. Keeping changes in Envy's own container also means one click gives changes, secrets, and the activity graph together.

Other surfaces

Feature What it does
Secrets tree Browse and edit by profile in the sidebar
Activity Graph Git-graph-style webview + live "Unsynced Changes" card with inline actions
Inline hover Hover process.env.FOO to see its value
CodeLens Actions above .env files
Drift watcher Status-bar badge when local and cloud diverge (configurable interval)
Startup health check Runs envy check on activation, surfaces expiring secrets
Blame panel Per-key history webview
Embedded dashboard envy ui inside an editor tab

25 commands registered, all under the Envy: prefix.

Settings

Setting Default Purpose
envy.cliPath envy Path to the CLI binary
envy.defaultProfile dev Profile for operations without explicit selection
envy.dashboardPort 8888 Port for the embedded dashboard
envy.autoCheckOnStartup true Run envy check on activation
envy.driftCheckIntervalMinutes 5 Drift polling interval

Security note on process spawning

The extension never uses shell: true. It previously did on Windows with hand-rolled quoting whose trigger set missed %, !, (, ), and backtick — and whose escape (\") is the C-runtime convention, not cmd.exe's (^"). A secret value like a" & calc & "b would execute calc. Since secret values and project slugs can originate from a remote server or a teammate, that was a remote-code-execution path onto a developer's machine. Arguments now go through as a real argv array with Node's own Win32 escaping.


The Local Dashboard (envy ui)

envy ui                 # http://localhost:8888, opens browser
envy ui --port 3000
envy ui --no-open

A full dark-mode dashboard served by Python's built-in http.serverzero external web framework. No Flask, no FastAPI, no npm. The entire HTML/CSS/JS is an inline template in web_ui.py.

Why: the CLI's dependency footprint is a feature. Adding Flask to get a UI would mean everyone installing envy pays for a web framework they may never run. http.server is stdlib, so envy ui costs nothing until invoked and works in air-gapped environments.

Features: profile sidebar with counts and inheritance, sortable secrets table with click-to-reveal, instant client-side search, add/edit/delete with schema validation, profile creation with --extends, and a visual profile diff (missing red / extra green / changed yellow).

Security: binds exclusively to 127.0.0.1 — not reachable from other machines. Decryption happens server-side in your own Python process; the browser receives plaintext over loopback only.


Workflows & Recipes

Onboarding a new developer

# Admin (web dashboard): add them to the project, verify their key fingerprint

# New developer:
pip install envy-secrets
envy cloud login
envy cloud clone my-project --env development
envy run dev -- npm start

From nothing to running app with real secrets in four commands.

Multi-environment with inheritance

envy set DATABASE_URL=postgres://prod-db --profile prod
envy set REDIS_URL=redis://prod-cache   --profile prod
envy set API_KEY=live-key               --profile prod

envy profile create staging --extends prod
envy set DATABASE_URL=postgres://staging-db --profile staging

envy run staging -- python app.py   # inherits REDIS_URL + API_KEY automatically

CI/CD

- run: pip install envy-secrets
- run: envy cloud login --headless --email "$ENVY_EMAIL"   # password via stdin/env
- run: envy cloud clone myproject --env production
- run: envy check                                          # fail build on expired secrets
- run: envy run prod -- npm run build

Rotation with enforcement

envy schema init
envy set API_KEY=old-key --expires 30d
envy check                              # exit 1 when it expires → CI catches it
envy set API_KEY=new-key --expires 30d
envy cloud push --message "Rotate API key"

Migrating from plaintext .env

envy init
envy import .env
rm .env                    # already gitignored, but remove the plaintext copy
envy run dev -- npm start

HTTP API Reference

Base URL: https://envy-baq3.onrender.com/api. All authenticated routes accept a Bearer token or an HttpOnly cookie.

Authentication

Method Endpoint Auth Description
POST /auth/register Register with SRP verifier + E2EE key material
POST /auth/srp/challenge SRP step 1 — returns salt + server ephemeral B
POST /auth/srp/verify SRP step 2 — verify M1, return M2 + token
POST /auth/webauthn/register-options Passkey registration options
POST /auth/webauthn/register-verify Verify and store a passkey
POST /auth/webauthn/login-options Authentication options
POST /auth/webauthn/login-verify Verify assertion, return token + E2EE material
POST /auth/cli-callback Build the one-time loopback redirect for CLI login
GET /auth/me Current user + E2EE material + key fingerprint
POST /auth/logout Clear the cookie
POST /auth/logout-all Revoke every issued token (bumps tokenVersion)
PUT /auth/update-profile Update name/email/avatar
POST /auth/upload-keys Upload E2EE keys (for pre-E2EE accounts)
GET /auth/keys/:userId A user's public key + fingerprint
PATCH / DELETE /auth/webauthn/:id Rename / delete a passkey
POST /auth/sudo/webauthn/options Sudo step-up options (userVerification: required)
POST /auth/sudo/webauthn/verify Verify → 5-minute sudo token
POST /auth/sudo/srp/challenge Sudo via password, step 1
POST /auth/sudo/srp/verify Sudo via password, step 2
POST /auth/recovery-codes ✅ + sudo Store a set of wrapped codes (replaces any previous set)
GET /auth/recovery-codes/status Count + hints remaining — never the blobs
POST /auth/recovery-codes/challenge Wrapped blobs for an email to attempt locally
POST /auth/recovery-codes/consume Burn a code → session token

The two public recovery endpoints are rate-limited and return an identical shape for real and nonexistent accounts, so they can't be used to enumerate which emails have accounts.

Projects

Method Endpoint Access Description
GET /projects member List your projects (paginated, searchable)
GET /projects/explore any Public project directory
POST /projects any Create
GET /projects/:slug viewer+ Full detail
PUT /projects/:slug admin + sudo Update
DELETE /projects/:slug admin + sudo Delete (cascades activities)
POST /projects/:slug/star viewer+ Star / unstar
GET /projects/users/search any Find users to add

Team

Method Endpoint Access
GET /projects/:slug/team viewer+
POST /projects/:slug/team admin + sudo
PUT /projects/:slug/team/:userId admin + sudo
DELETE /projects/:slug/team/:userId admin + sudo

Secrets (E2EE — the modern path)

Method Endpoint Access Description
POST /projects/:slug/env-e2ee/:environment write + sudo Push an encrypted blob (+ keyCount)
GET /projects/:slug/clone-e2ee/:environment read Fetch the encrypted blob
POST /projects/:slug/env-e2ee/:environment/sync-count write Self-heal the displayed key count
GET /projects/:slug/keys viewer+ Your wrapped project key
POST /projects/:slug/keys/:userId admin (+ sudo unless self) Provision a wrapped key for a member
POST /projects/:slug/rotate-key admin + sudo Atomic key rotation
GET /projects/:slug/env-e2ee/:environment/history read Version list (metadata + decryptable flag, no blobs)
GET /projects/:slug/env-e2ee/:environment/history/:shortId read One version's encrypted blob
POST /projects/:slug/env-e2ee/:environment/restore write + sudo Roll back — appends a new version; 409 across a key rotation

Secrets (legacy plaintext path)

Method Endpoint Access
POST /projects/:slug/env/:environment write + sudo
DELETE /projects/:slug/env/:environment/:key write + sudo
POST /projects/:slug/env/:environment/bulk write + sudo
GET /projects/:slug/clone/:environment read

Activity & chat

Method Endpoint Access
GET /activities/project/:slug viewer+ (fields restricted below developer)
GET /activities/project/:slug/stats viewer+
GET /activities/:id viewer+
GET /chat/sessions · /chat/history/:id · POST /chat/stream

Rate limits

Scope Limit
Account creation, challenge issuing 30 / 15 min per IP
Proof verification (SRP verify, passkey verify, sudo) 10 / 15 min per IP
Chat 10 / day per user (atomic)

Engineering Decisions & Rationale

Why the Git metaphor

Not decoration — a deliberate reduction in cognitive load. Every developer already has a precise mental model for init/add/commit/push/pull/clone/diff/blame. Reusing those verbs means correct guesses about behavior before reading any docs, and it makes the conceptual claim ("your secrets should be versioned, attributed, and synced like code") self-evident.

The commitment is real: pull genuinely merges, blame genuinely attributes, remote genuinely persists. Borrowing the vocabulary without matching the semantics would be worse than not borrowing it.

Why CLI-first, not dashboard-first

Secrets are consumed by processes, not people. The critical path is "get this value into npm start," which is inherently a terminal operation. A dashboard-first tool inevitably makes the CLI a second-class afterthought — and then can't be used in CI, over SSH, or in a container, which is exactly where secret injection matters most.

Building CLI-first also forced a clean data model, since everything had to be expressible without a UI to paper over gaps.

Why zero-knowledge, given the cost

It genuinely costs: no password recovery, client-driven key rotation, harder team management, no server-side search over secrets.

It buys the only thing that actually matters here: the answer to "what if Envy Cloud gets breached?" is "attackers get ciphertext." Not "we have strong access controls" — a mathematical property rather than an operational promise. For a secrets product from an individual developer, that's the difference between something you could reasonably trust and something you couldn't.

It also removes a category of liability: a compelled-disclosure order can't produce plaintext that doesn't exist server-side.

Why AES-256-GCM over ChaCha20-Poly1305

Both are excellent AEADs. AES-GCM wins on hardware: AES-NI is on every x86-64 and ARM64 CPU from the last decade, making it substantially faster than ChaCha20 in software. ChaCha20's advantage is on hardware without AES acceleration — not the environment developer machines and CI runners are in. AES-GCM is also what TLS 1.3 negotiates by default, so it's the more scrutinized path.

Fernet (AES-128-CBC + HMAC) was removed in v2.0: weaker key size, no AEAD in a single pass, and a token format that carries needless overhead.

Why X25519 sealed boxes over RSA

Smaller keys (32 bytes vs 256+), faster operations, no parameter selection to get wrong (no key size choice, no padding-scheme choice — RSA has PKCS#1 v1.5 vs OAEP, and picking wrong is catastrophic). Curve25519 was designed to be misuse-resistant. Signal, WireGuard, and TLS 1.3 all made the same call.

Sealed boxes specifically add anonymity (no sender key to manage or leak) and authentication, in a single primitive that's hard to hold wrong.

Why per-secret encryption locally, per-blob in the cloud

Different constraints:

Locally, per-secret encryption gives partial reads, corruption isolation, and meaningful diffs of secrets.json.

In the cloud, per-blob prevents the server from learning your key names — a real leak. STRIPE_SECRET_KEY and AWS_SECRET_ACCESS_KEY tell an attacker a great deal about your infrastructure even without values. It also means one round-trip per environment instead of N. The cost is losing partial updates, which is fine because the client already holds the full decrypted set.

The keyCount field exists to restore just the count for UI display, without leaking names.

Why sessionStorage for the private key

The private key must be in the page — the crypto runs there. The real choice is where between page loads.

Option Problem
Memory only Every refresh forces a full re-unlock. Users route around friction by staying logged in forever or picking weak passwords.
localStorage Survives browser restart — a far larger exposure window.
sessionStorage Survives refresh, dies with the tab.

sessionStorage is the narrowest window that keeps the UX honest. XSS still reads it, which is why the CSP is script-src 'self' with no unsafe-inline — blocking injected scripts from executing at all, rather than hoping they don't find the key.

Why the extension shells out to the CLI

It could reimplement the crypto in TypeScript. That would mean two implementations of every primitive, drifting apart — and the Argon2id incident is exactly what that looks like in practice, from just one divergence.

Shelling out means one implementation, one place to fix bugs, and an extension that inherits every CLI improvement free. The cost — process spawn latency — is unnoticeable for these operations.

Why MongoDB

The data is genuinely document-shaped: a project holds a variable-length team array, a map of environments to opaque blobs, and a map of environments to counts. Environments are user-defined, so the schema is dynamic by nature — a relational model would need either EAV tables or JSON columns, at which point the relational guarantees aren't buying much.

Mongoose adds schema validation, middleware hooks (the slug generator, the shortId generator), and virtuals.

The tradeoff is honest: no transactions across documents in the current deployment, which is why key rotation is written as a single atomic document update rather than a multi-document transaction.

Why the local dashboard has zero dependencies

Every dependency in the CLI is paid for by every user, including those who never run envy ui. http.server is stdlib. The tradeoff — an inline HTML template instead of a component framework — is worth it for a UI this contained, and it means envy ui works in air-gapped environments with no wheel-building.

Why immutable slugs

Slugs previously regenerated whenever name changed. That silently broke every envy cloud remote add <slug> and every extension config pointing at the project — a rename in a web UI breaking terminals on other machines, with no error explaining why.

Slugs are now generated once at creation and never regenerated, like a GitHub repo's internal ID. Renaming changes the display name only.

Related: a name of only non-alphanumerics ("---", "中文") previously sanitized to an empty slug, producing a permanently unreachable project. It now falls back to project-{id-suffix}.

Why sudo mode exists on top of sessions

Session tokens live 7 days for usability. But "you authenticated a week ago" is inadequate authorization for "destroy every secret in production" or "grant this stranger decrypt access."

Sudo mode re-proves possession of the credential within 5 minutes for exactly the destructive/privilege-granting operations. Sessions handle "who are you"; sudo handles "are you still there, right now, and did you mean this."

Why activity logging stores key names but never values

An audit trail that says "someone changed something" is nearly useless; one that says "Krishna updated DATABASE_URL in production from the CLI 2 hours ago" is genuinely actionable during an incident.

Key names plus action verbs deliver almost all the operational value at almost none of the confidentiality cost. Values are never sent on the E2EE path — the client sends metadata alongside the blob, and the server has no way to extract more.


Security Posture

v2.3.0 audit

v2.3.0 shipped a full audit of the CLI, API, web client, and extension — 33 findings, all remediated.

Critical

  1. envy cloud register uploaded the real private key in cleartext (swapped tuple unpacking) — plus two independent bugs in the same path (Argon2id/PBKDF2 KDF mismatch, wrong plaintext serialization format) that made CLI-created vaults permanently unrecoverable.
  2. CLI login had no CSRF state and used a fixed port — session-hijack window during login.
  3. Open redirect in cli-callback (?port=1@evil.com) leaked the session token to arbitrary domains.
  4. The entire E2EE write path was missing requireSudo — client-side-only enforcement.
  5. Team member removal didn't revoke or rotate keys.

High 6. No rate limiting on any auth endpoint. 7. Windows command injection in the extension (shell: true + broken quoting). 8. Session token and vault written world-readable (0644) and non-atomically. 9. Team roles were broken in both directions (UI sent developer → 400; validator accepted member → 500). 10. Email validation rejected modern TLDs (.dev, .codes, .tech) — including this project's own domain. 11. ReDoS and NoSQL operator injection in search. 12. No CSP anywhere.

Medium — WebAuthn origin trusted from the request header; sudo used userVerification: "preferred"; no authorization on the star route; project keys provisionable for non-members; slug regeneration on rename; unvalidated environments; orphaned activities on delete; IPs and value previews leaked to public-project viewers; unvalidated pagination (?limit=abc → 500); non-atomic chat rate limiting; untrimmed CORS_ORIGINS; no token revocation; err.message leaked in production.

Current hardening

Layer Measures
Transport HTTPS everywhere, HSTS via helmet
Headers CSP (script-src 'self'), X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, X-Powered-By removed
Auth SRP-6a, WebAuthn, sudo step-up, tokenVersion revocation, rate limiting
Input express-validator on every mutating route, regex escaping, type guards against operator injection, bounded pagination
Crypto AES-256-GCM + AAD binding, X25519 sealed boxes, PBKDF2 600k, key rotation, fingerprint verification
Local files 0600, atomic writes
Proxy trust proxy: 1 — exactly one hop, so client IPs are real and rate limits key correctly
Errors Generic messages in production; details only in development

Reporting a vulnerability

Please open an issue or contact the maintainer directly — without exploit details in a public report.


Operations & Deployment

Topology

Component Platform Notes
API Render Node 20+, single instance behind one proxy hop
Database MongoDB Atlas
Web client Vercel Static SPA + security headers from vercel.json
CLI PyPI Published via GitHub Actions OIDC Trusted Publishing
Extension VS Code Marketplace Published via vsce with a VSCE_PAT secret

Server environment variables

PORT=5000
NODE_ENV=production

MONGODB_URI=mongodb+srv://...

JWT_SECRET=<long random string>
JWT_EXPIRE=7d

CLIENT_URL=https://envy.krishnajain.codes
CORS_ORIGINS=https://envy.krishnajain.codes,http://localhost:5173

# Pin these in production rather than deriving from the request
WEBAUTHN_ORIGIN=https://envy.krishnajain.codes
WEBAUTHN_RP_ID=envy.krishnajain.codes

# Optional — AI assistant
STAGING_ANTHROPIC_API_KEY=...
STAGING_AGENT_STANDARD_MODEL=claude-sonnet-5

# Optional — required only to run more than one instance (see below)
REDIS_URL=redis://localhost:6379

# Optional — observability
LOG_LEVEL=info           # trace|debug|info|warn|error|fatal
SENTRY_DSN=https://...   # unset → Sentry disabled entirely
SENTRY_TRACES_SAMPLE_RATE=0.1
METRICS_TOKEN=<random>   # unset → /api/metrics is open; set it in production

CORS_ORIGINS entries are trimmed on parse — "a.com, b.com" previously produced " b.com" with a leading space that silently never matched.

Running more than one instance

Login (SRP) sessions and rate-limit counters live in process memory by default. That's correct and fastest for a single instance, but with two instances behind a load balancer, a login started on one and completed on the other fails, and rate limits become per-instance rather than global.

Set REDIS_URL and both move to Redis. Nothing else changes — the code path is identical, and if Redis is unreachable at boot the server logs a warning and degrades to in-memory rather than refusing to start, on the reasoning that a degraded secrets API beats an unavailable one.

Health checks & metrics

Endpoint Purpose
GET /api/health Liveness. Deliberately touches nothing external, so a brief database blip can't trigger a restart loop.
GET /api/ready Readiness. Pings MongoDB; 503 when it's down, so a load balancer routes around a sick instance instead of failing user requests.
GET /api/metrics Prometheus exposition. Guarded by METRICS_TOKEN when set (Authorization: Bearer …).

Point your orchestrator's liveness probe at /api/health and its readiness probe at /api/ready. Pointing liveness at a DB-checking endpoint is the classic mistake — it converts a database hiccup into a rolling restart of every instance, exactly when you least want it.

Beyond default process metrics, the API exports request duration/count by route template, envy_auth_attempts_total by method and outcome (the credential-stuffing signal), rate-limit hits, secret push/pull/rotate volume, and key rotations. Route labels have ObjectIds and long segments scrubbed so cardinality stays bounded.

Logging

Structured JSON via pino, one request ID per request, echoed back in the X-Request-Id header and in error responses — so "I got an error at 14:32" maps to an exact log line. Passwords, tokens, encrypted blobs, key material, recovery codes, and Shamir shares are redacted before anything is written; the same scrubbing runs on Sentry events before they leave the process.

Client environment variables

VITE_API_URL=https://envy-baq3.onrender.com/api

CI/CD

Workflow Trigger Does
tests.yml Every push and PR CLI tests on Python 3.10 + 3.12, client tests, server tests, plus the crypto-vector reproducibility check
publish.yml GitHub Release (non-envy-vscode-* tags) or manual Clean dist/, build, publish to PyPI via OIDC with skip-existing
vscode-ci.yml Push/PR touching envy-vscode/** npm ci, type-check, dry-run package
vscode-release.yml Manual dispatch npm versionbump-changelog.js → commit + tag → package → Marketplace publish → GitHub Release with the .vsix

The PyPI workflow filters out envy-vscode-* tags so extension releases don't trigger a Python publish. It explicitly cleans dist/ first — python -m build only adds to it, so a stale artifact would cause a re-upload of an already-published version, which PyPI rejects.


Development & Contributing

git clone https://github.com/KRISHNA-JAIN15/ENVY.git
cd ENVY

# CLI
python -m venv venv && source venv/bin/activate
pip install -e ".[dev]"
pytest
black . && ruff check .

# Server
cd server && npm install && cp .env.example .env && npm run dev

# Client
cd client && npm install && cp .env.example .env && npm run dev

# Extension
cd envy-vscode && npm install && npm run compile   # then F5 in VS Code

Tests

pytest                                   # CLI + library
pytest --cov=envy --cov-report=term-missing

cd client && npm test                    # web crypto: conformance, Shamir, recovery
cd server && npm test                    # API, against a real in-memory MongoDB

259 tests, run on every push and pull request. Two parts are worth knowing about before you change anything cryptographic.

Cross-language conformance. tests/fixtures/crypto_vectors.json is a committed set of known inputs and their expected ciphertexts. Both the Python CLI and the JavaScript client must reproduce it exactly. This exists because the worst bug this project ever shipped was the Python and browser implementations silently disagreeing on how to wrap a private key — one used Argon2id, the other PBKDF2 — which made every CLI-registered vault permanently unopenable, with no error anywhere to indicate a problem. Unit tests on each side passed the whole time. Only a shared fixture catches that class of bug.

CI regenerates the fixture and diffs it against the committed copy, so any change to a wire format fails the build. If you meant to change one, regenerate deliberately:

python tests/generate_vectors.py         # then commit the diff, and say why

Property-based tests. hypothesis generates the inputs for the invariants that must hold for all values, not just chosen ones — round-trip fidelity, wrong key always raising InvalidTag (never returning wrong-but-plausible plaintext), AAD binding rejecting a blob moved between environments, and inheritance resolution terminating on cyclic profiles.

The API tests run against a real MongoDB via mongodb-memory-server and drive real HTTP through supertest — no mocked database, no mocked router, because the bugs worth catching here are in the interaction between middleware, roles, and queries.

Repository layout

ENVY/
├── envy/                    # Python CLI + library
│   ├── main.py              # Typer app — every command
│   ├── crypto.py            # AES-GCM, X25519, SRP-6a, KDF, keyring
│   ├── storage.py           # secrets.json I/O, inheritance resolution
│   ├── schema.py            # Validation engine
│   ├── hooks.py             # Git pre-commit integration
│   ├── utils.py             # Atomic writes, formatting, validation
│   └── web_ui.py            # Local dashboard (stdlib only)
├── tests/                   # CLI tests + the cross-language crypto fixture
│   ├── fixtures/crypto_vectors.json
│   └── generate_vectors.py
├── server/                  # Express 5 API
│   ├── app.js               # Express app — no DB, no port (so it's testable)
│   ├── index.js             # Process: config, DB, listen, graceful shutdown
│   ├── models/              # User, Project, SecretVersion, Activity, Chat*
│   ├── routes/              # auth, projects, activities, chat
│   ├── middleware/auth.js   # protect, authorize, requireSudo
│   ├── controllers/         # chatController
│   ├── config/              # db, corsOrigins, logger, metrics, redis
│   ├── tests/               # API tests (mongodb-memory-server + supertest)
│   └── utils/               # publicKeyFingerprint, pagination, systemPrompt
├── client/                  # React 19 + Vite 7
│   └── src/{pages,components,context,services,hooks,styles}
├── envy-vscode/             # VS Code extension
│   └── src/{cli,views,commands,webview,decorations,git,auth,status,health}
├── .github/workflows/
├── CHANGELOG.md
└── pyproject.toml

Versioning

Semantic versioning, two independently-versioned artifacts:

  • Python packagepyproject.toml and envy/__init__.py must match (envy version reads the latter).
  • Extension — version bump, changelog conversion, tag, and publish are all done together by vscode-release.yml. Add notes to ## [Unreleased]; let the workflow assign the number.

Contributing

  1. Fork and branch
  2. Make changes with clear commits
  3. Add tests
  4. pytest passes
  5. black . and ruff check .
  6. Open a PR describing what and why

Troubleshooting

Symptom Cause & fix
"Envy not initialized" No .envy/ in the current directory. Run envy init. Envy does not search parent directories — run from the project root.
"You don't have access to project X" Usually authenticated as a different account. Check envy cloud status; envy cloud logout and log in again. Also verify the actual slug in the dashboard URL.
"E2EE keys not found" Keys aren't in the OS keyring. Re-run envy cloud login; if your passkey lacks PRF, complete the vault-unlock prompt.
"Wrong password" on a known-good password If the account was created by envy cloud register before v2.3.0, its vault is unrecoverable due to the KDF bug. See the security notice at the top.
secrets.json corrupted Pre-v2.3.0 crash during a non-atomic write. Restore from backup or delete to start fresh. Writes are now atomic.
Commands not found after install The Python scripts directory isn't on PATH. Try python -m envy.main --help.
CI publish rejected by PyPI A stale artifact in dist/. The workflow now cleans it and uses skip-existing.
Extension shows stale data Run Envy: Refresh. If two extension versions are installed, uninstall and fully restart VS Code.
Interactive program hangs under envy run Masking pipes stdout, so no TTY. Use --no-mask.

FAQ

How is this different from dotenv? dotenv loads plaintext files. Envy encrypts at rest, supports profiles and inheritance, enforces schemas, syncs with zero-knowledge E2EE, masks logs, audits changes, and provides three UIs. dotenv is a file loader; Envy is a secret management platform.

Can I use my existing .env? Yes — envy import .env encrypts every variable.

What if I lose my master key? Without .envy/master.key (and the keyring copy), local secrets can't be decrypted. Back it up. If you use Envy Cloud, secrets are recoverable by re-cloning.

What if I forget my master password? Use a recovery code, if you generated them — see Recovery. The server still holds nothing that can decrypt your vault; a recovery code is a second key you hold, and the server only stores ciphertext it can't open. If you never generated codes, it really is unrecoverable — that's the cost of zero-knowledge, and the reason to generate them now rather than the day you need them.

What if the person who set up our project leaves, or is unreachable? That's what Shamir shares are for: split the project key so any k of n admins can reconstruct it. Again, this only works if you set it up in advance. See Recovery.

Is it safe to commit secrets.json? Cryptographically, yes — it's only AES-256-GCM ciphertext. But envy init gitignores it by default, deliberately: committed ciphertext means a future key compromise retroactively exposes your history through the Git log, in every fork. Remove the .gitignore line if you want a self-contained repo; it's a supported choice.

Can I use this in production? Yes. Use envy run prod -- <cmd> so secrets never touch disk, set --expires and run envy check in CI, and give production access only to admin.

Does envy ui expose secrets to the network? No — it binds exclusively to 127.0.0.1.

Will envy cloud clone delete my local changes? No. It merges: remote keys are added, conflicts take the remote value, and local-only keys are untouched.

Is it compatible with my authenticator? Yes — standard WebAuthn. Touch ID, Windows Hello, YubiKey, 1Password, Microsoft Authenticator all work. For one-step passwordless vault unlock you need PRF support (hardware keys and device-local passkeys; cloud-synced managers usually drop it).

Why would I make a project public? Three real cases: an .env.example replacement where contributors get the correct schema via one command; genuinely public configuration (PUBLIC_API_URL, feature flags); and forkable starter configurations. Never put real secrets in a public project — public grants every authenticated user viewer on development.

Does the CLI work inside a virtualenv? Yes, and better than you'd expect. ~/.envy_cloud.json is at your OS home directory, so every venv on the machine shares one identity. Log in once, logged in everywhere — like git.


Known Limitations

Stated plainly, because a security tool that hides its edges isn't trustworthy.

  1. One key per project, not per environment. All environments share a Project Master Key. AAD binding prevents blob-swapping, but a developer who obtains production ciphertext through some other channel could decrypt it. True cryptographic separation needs per-environment keys — a meaningful future change.

  2. Server-mediated key distribution. A malicious server could substitute its own public key when you add a teammate. Fingerprints make this detectable if verified out-of-band; nothing makes it impossible. Inherent to any server-hosted key directory.

  3. Log masking is best-effort. Exact per-line substring matching. Re-encoded, transformed, or line-split values pass through.

  4. Recovery is opt-in and only as good as where you put it. Recovery codes and Shamir shares exist now (see Recovery), but they're generated on request, not by default — if you never generate them, a forgotten master password is still terminal. And a recovery code stored next to the password it recovers protects against forgetting, not against compromise.

  5. Rotation needs everyone to have keys. If a remaining member hasn't set up E2EE, rotation is skipped with a warning rather than locking them out.

  6. Version history does not survive key rotation. Removing a member rotates the project key, permanently sealing every version written before that point. Intentional — see the note under History & rollback — but it does mean rollback can't cross a team change.

  7. No upward directory search. Unlike git, Envy only looks at .envy/ in the current directory. Running from a subdirectory creates a second vault. (find_envy_root exists in utils.py but isn't yet wired into command resolution.)

  8. Legacy plaintext path still present. The pre-E2EE envVariables routes remain for backward compatibility. New projects never use them, but they exist.


License

MIT — see LICENSE.


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

envy_secrets-2.4.0.tar.gz (122.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

envy_secrets-2.4.0-py3-none-any.whl (91.4 kB view details)

Uploaded Python 3

File details

Details for the file envy_secrets-2.4.0.tar.gz.

File metadata

  • Download URL: envy_secrets-2.4.0.tar.gz
  • Upload date:
  • Size: 122.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for envy_secrets-2.4.0.tar.gz
Algorithm Hash digest
SHA256 1478d631e0c40fc3ed030b15339463a935d261e1617a7bb79c5b30c7f66ab0f4
MD5 e02449b9a6f085efd6ba9a228a3eab9e
BLAKE2b-256 aac8c8afc3f5dbc0f8c2615c291fc8150be10f256d47485df57bef3bfba8f4d7

See more details on using hashes here.

Provenance

The following attestation bundles were made for envy_secrets-2.4.0.tar.gz:

Publisher: publish.yml on KRISHNA-JAIN15/ENVY

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file envy_secrets-2.4.0-py3-none-any.whl.

File metadata

  • Download URL: envy_secrets-2.4.0-py3-none-any.whl
  • Upload date:
  • Size: 91.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for envy_secrets-2.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 08f4b527e4ee6b89fa3fa7c6f393d195e903a306c188efd3bb95b2084dacdc4e
MD5 20ca9fdad2dfa1146e399ca64f6798a0
BLAKE2b-256 3aa1ebd40a1a9c6aa42ca039eef2bd913d725808ca2c6678547f5cf96576595d

See more details on using hashes here.

Provenance

The following attestation bundles were made for envy_secrets-2.4.0-py3-none-any.whl:

Publisher: publish.yml on KRISHNA-JAIN15/ENVY

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

2.7.0

2 files

2.4.1

2 files

This release

2.4.0 This release

2 files

2.3.0

2 files

2.2.0

2 files

2.0.6

2 files

2.0.5

2 files

2.0.3

2 files

2.0.2

2 files

2.0.1

2 files

2.0.0

2 files

0.1.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page