Metadata-Version: 2.5
Name: envy-secrets
Version: 2.4.0
Summary: Git for your .env files - Zero-knowledge encrypted secrets with team sync, versioned rollback, and recovery
Project-URL: Homepage, https://github.com/KRISHNA-JAIN15/ENVY
Project-URL: Documentation, https://github.com/KRISHNA-JAIN15/ENVY#readme
Project-URL: Repository, https://github.com/KRISHNA-JAIN15/ENVY
Project-URL: Issues, https://github.com/KRISHNA-JAIN15/ENVY/issues
Author-email: Krishna Jain <krishnajain1502@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: cli,cryptography,devops,dotenv,e2ee,encryption,env,environment,secrets,secrets-management,security,zero-knowledge
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security
Classifier: Topic :: Security :: Cryptography
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Requires-Dist: cryptography<43.0.0,>=41.0.0
Requires-Dist: keyring<26.0.0,>=24.0.0
Requires-Dist: pynacl<2.0.0,>=1.5.0
Requires-Dist: pyyaml<7.0.0,>=6.0.0
Requires-Dist: requests<3.0.0,>=2.28.0
Requires-Dist: rich<14.0.0,>=13.0.0
Requires-Dist: typer[all]<1.0.0,>=0.9.0
Provides-Extra: dev
Requires-Dist: black>=23.0.0; extra == 'dev'
Requires-Dist: hypothesis>=6.0.0; extra == 'dev'
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
Requires-Dist: pytest>=7.0.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Description-Content-Type: text/markdown

<div align="center">

# 🔐 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](https://badge.fury.io/py/envy-secrets.svg)](https://pypi.org/project/envy-secrets/)
[![Python Version](https://img.shields.io/pypi/pyversions/envy-secrets.svg)](https://pypi.org/project/envy-secrets/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Downloads](https://pepy.tech/badge/envy-secrets)](https://pepy.tech/project/envy-secrets)

[Install](#installation) • [Quick Start](#quick-start) • [Concepts](#core-concepts) • [Cryptography](#the-cryptographic-design) • [Commands](#complete-command-reference) • [API](#http-api-reference) • [Why](#engineering-decisions--rationale) • [Changelog](CHANGELOG.md)

**Live:** [envy.krishnajain.codes](https://envy.krishnajain.codes) · **Package:** [pypi.org/project/envy-secrets](https://pypi.org/project/envy-secrets/)

</div>

---

> ### ⚠️ 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](CHANGELOG.md#230---2026-08-03).

---

## Table of Contents

**Understanding Envy**
- [The Problem](#the-problem)
- [What Envy Actually Is](#what-envy-actually-is)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [System Architecture](#system-architecture)
- [Core Concepts](#core-concepts)

**How It Works**
- [The Cryptographic Design](#the-cryptographic-design)
- [Authentication Deep Dive](#authentication-deep-dive)
- [Access Control Model](#access-control-model)
- [Data Formats & Storage](#data-formats--storage)

**Using Envy**
- [Complete Command Reference](#complete-command-reference)
- [Feature Deep Dives](#feature-deep-dives)
- [The Web Dashboard](#the-web-dashboard)
- [The VS Code Extension](#the-vs-code-extension)
- [The Local Dashboard](#the-local-dashboard-envy-ui)
- [Workflows & Recipes](#workflows--recipes)

**Reference**
- [HTTP API Reference](#http-api-reference)
- [Engineering Decisions & Rationale](#engineering-decisions--rationale)
- [Security Posture](#security-posture)
- [Operations & Deployment](#operations--deployment)
- [Development & Contributing](#development--contributing)
- [Troubleshooting](#troubleshooting)
- [FAQ](#faq)
- [Known Limitations](#known-limitations)

---

## 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](#why-the-git-metaphor).

### 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

```bash
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:**
```bash
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](https://envy.krishnajain.codes) | Team management, visual editing, activity graph |
| **Local dashboard** | Bundled — run `envy ui` | Offline visual management, zero external dependencies |

### Development install

```bash
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)

```bash
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)

```bash
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`:

```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.

```bash
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](#log-masking-and-its-honest-limits) |

### 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](#recovery) 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.

```bash
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

```bash
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](#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`

```jsonc
{
  "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 → `fsync` → `chmod 0600` → `os.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`

```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

**`users`** — `name`, `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.

**`projects`** — `name`, `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`.

**`activities`** — `project`, `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.

```bash
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.

```bash
# 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.

```json
{
  "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` |

```console
$ 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

```console
$ 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.

```console
$ 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

```bash
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

```bash
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](https://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](#why-sessionstorage-for-the-private-key).

---

## 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](https://github.com/microsoft/vscode/issues/185269) 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`)

```bash
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.server` — **zero 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

```bash
# 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

```bash
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

```yaml
- 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

```bash
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`

```bash
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](https://github.com/KRISHNA-JAIN15/ENVY/issues) 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

```bash
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](https://getpino.io), 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

```bash
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 version` → `bump-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

```bash
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

```bash
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:

```bash
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 package** — `pyproject.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](#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](#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](#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](#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](LICENSE).

---

<div align="center">

**Built by [Krishna Jain](https://github.com/KRISHNA-JAIN15)**

[⭐ Star on GitHub](https://github.com/KRISHNA-JAIN15/ENVY) · [🐛 Report a Bug](https://github.com/KRISHNA-JAIN15/ENVY/issues) · [📖 Docs](https://envy.krishnajain.codes/docs) · [📦 PyPI](https://pypi.org/project/envy-secrets/)

</div>
