xenv lock / unlock — asymmetric key distribution for teams

Open
#8 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
5/5
Estimated time
Over a week
Newbie friendliness
35/100
Issue type
Feature
Clarity
Mostly clear
Activity status
Quiet
Tech stack
shell, typescript
Domain
cli, security

Research direction

Start with the command wiring in src/cli.ts and src/args.ts, then inspect the existing key and vault flows in src/vault.ts and src/init.ts. Implement the lock, unlock, recipient-list, doctor, and MCP integrations described in the issue, including key rotation on removal and auto-relocking. Done means the new commands work with age while existing solo, CI, SDK, and vault flows remain unchanged.

Written by the indexing model from the issue text.

Description

enhancement

summary

Add xenv lock / xenv unlock — a key distribution layer that wraps symmetric encryption keys with asymmetric crypto, so teams can share vault access without pasting keys in Slack.

The insight: don't re-encrypt vaults per-recipient (what SOPS/GPG do). Instead, encrypt the symmetric key once per recipient. Vaults never change. Adding/removing team members only touches the key wrapper, not the secrets.

design

architecture
.xenv.recipients      # committed — who has access (SSH pubkeys / age pubkeys)
.xenv.keys.age        # committed — .xenv.keys encrypted for all recipients
.xenv.keys            # gitignored — decrypted locally (same as today)
.xenv.production.enc  # committed — vault, unchanged

Two new files. Everything downstream (cascade, SDK, MCP) is unchanged — they read .xenv.keys which was already decrypted by xenv unlock.

commands
# add a team member (fetches SSH keys from GitHub)
xenv lock --add ahoward
xenv lock --add teammate

# see who has access
xenv lock --list

# remove someone (rotates key + re-encrypts vaults automatically)
xenv lock --remove former-teammate

# decrypt .xenv.keys.age → .xenv.keys using your SSH key
xenv unlock
how it works
  1. xenv lock --add <github-user> fetches their SSH public keys via gh api /users/<user>/keys

  2. Appends the public key to .xenv.recipients (one per line, age format)

  3. Encrypts .xenv.keys → .xenv.keys.age using age -R .xenv.recipients

  4. .xenv.recipients and .xenv.keys.age are committed

  5. Recipient clones the repo and runs xenv unlock

  6. age -d -i ~/.ssh/id_ed25519 .xenv.keys.age > .xenv.keys

  7. They now have .xenv.keys locally (gitignored) and everything works

key resolution (unchanged)

xenv unlock produces .xenv.keys. From there, the existing 8-step key resolution takes over. The lock/unlock layer is above the hot path — xenv @env -- cmd never touches asymmetric crypto.

auto-re-lock

When .xenv.recipients exists, these commands auto-re-lock after modifying .xenv.keys:

  • xenv keygen @env
  • xenv rotate @env

This ensures .xenv.keys.age stays in sync.

revocation

xenv lock --remove <user> must:

  1. Remove them from .xenv.recipients
  2. Rotate ALL symmetric keys (they had access)
  3. Re-encrypt all vaults with new keys
  4. Re-lock .xenv.keys for remaining recipients
  5. Print a warning: "vaults re-encrypted — commit .xenv.keys.age and all .xenv.*.enc files"

This is the expensive operation and it should be explicit about what it's doing.

dependency: age

age (https://github.com/FiloSottile/age) is an optional dependency for lock/unlock only. It is:

  • ~5MB static binary, every platform
  • Handles SSH ed25519, RSA, and native age keys
  • Handles multiple recipients natively
  • Handles passphrase-protected keys and ssh-agent
  • Audited, widely trusted

The "zero deps" promise applies to xenv's core functionality. Team collaboration requiring age on PATH is a reasonable trade-off. xenv doctor should check for age availability and suggest brew install age / apt install age.

Solo developers never need age. CI never needs age (uses env vars). Only humans on teams need it.

implementation

This is ~100 lines of code in a new src/lock.ts, plus CLI wiring:

// lock --add: fetch pubkey, update recipients, re-encrypt
export async function lock_add(username: string, cwd: string): Promise<void>

// lock --remove: remove recipient, rotate keys, re-encrypt vaults
export async function lock_remove(username: string, cwd: string): Promise<void>

// lock --list: show recipients
export function lock_list(cwd: string): string[]

// unlock: decrypt .xenv.keys.age → .xenv.keys
export async function unlock(cwd: string): Promise<void>

The actual crypto is two shell-outs to age:

# lock (encrypt)
age -R .xenv.recipients -o .xenv.keys.age .xenv.keys

# unlock (decrypt)  
age -d -i ~/.ssh/id_ed25519 -o .xenv.keys .xenv.keys.age
files to change
  • src/lock.ts — new: lock/unlock logic
  • src/cli.ts — add lock/unlock commands
  • src/args.ts — add "lock", "unlock" to COMMANDS
  • src/doctor.ts — check for age binary when .xenv.recipients exists
  • src/init.ts — add .xenv.keys.age to gitignore if lock is used (actually: .xenv.keys.age should be committed, not gitignored)
  • src/vault.ts — runKeygen and rotate_vault_key call re-lock when .xenv.recipients exists
  • src/mcp.ts — add lock_add, lock_remove, lock_list, unlock tools
gitignore updates

.xenv.keys.age must NOT be in gitignore — it's the committed encrypted keyfile.
.xenv.recipients must NOT be in gitignore — it's the committed recipient list.
.xenv.keys stays gitignored as today.

security considerations

threat model

The lock/unlock feature protects against accidental key exposure and solves key distribution for teams. It does NOT protect against:

  • Malicious insiders with commit access (they can already modify code, CI, etc.)
  • Compromised CI with repo write access

This is the same threat model as the rest of xenv (and the same as git-crypt, SOPS, etc.).

key substitution

age encryption is authenticated (X25519+ChaCha20-Poly1305) but not signed — an attacker with commit access could replace .xenv.keys.age with a payload encrypted to their own key containing attacker-controlled symmetric keys. Mitigations:

  • Code review on .xenv.keys.age and .xenv.recipients changes (same as reviewing any security-sensitive file)
  • xenv unlock could warn when the fingerprint of the encrypting key changes (TOFU model)
  • Future: add SSH signature support (ssh-keygen -Y sign) for integrity
SSH key considerations
  • GitHub users may have multiple SSH keys — encrypt to all of them
  • When SSH keys rotate on GitHub, the lockfile needs re-locking (xenv lock --refresh)
  • age handles ssh-agent transparently for passphrase-protected keys

what stays the same

  • Solo developers: zero changes. .xenv.keys works exactly as today.
  • CI/production: still uses XENV_KEY_* env vars. Lock/unlock is for humans only.
  • @xenv/core SDK: unchanged. Reads .xenv.keys which was already decrypted.
  • MCP server: existing tools unchanged. New lock/unlock tools added.
  • ~/.xenv.keys global keyfile: orthogonal, no conflict.
  • Vault format: unchanged. Still AES-256-GCM.
  • Key resolution: unchanged. Still 8-step lookup.

UX flow

solo dev (nothing changes)
xenv init
xenv encrypt @production
xenv @production -- ./server
team setup
# team lead sets up
xenv init
xenv keygen @production
xenv encrypt @production
xenv lock --add ahoward
xenv lock --add alice
xenv lock --add bob
git add .xenv.recipients .xenv.keys.age .xenv.production.enc
git push

# alice clones
git clone ...
xenv unlock          # decrypts .xenv.keys using her SSH key
xenv @production -- ./server   # just works

# remove bob
xenv lock --remove bob   # rotates keys, re-encrypts all vaults
git add .xenv.recipients .xenv.keys.age .xenv.*.enc
git push
adding a new environment
xenv keygen @staging         # auto-re-locks if .xenv.recipients exists
xenv encrypt @staging
git add .xenv.keys.age .xenv.staging.enc

labels

enhancement, security, team

Dominant language
Shell
Stars
8
Forks
0
PR merge metrics
No merged PRs in 30d

Getting set up

This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from ahoward/xenv

All issues in ahoward/xenv

Similar issues

More Shell/Bash issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.