xenv lock / unlock — asymmetric key distribution for teams
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
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
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
-
xenv lock --add <github-user>fetches their SSH public keys viagh api /users/<user>/keys -
Appends the public key to
.xenv.recipients(one per line, age format) -
Encrypts
.xenv.keys→.xenv.keys.ageusingage -R .xenv.recipients -
.xenv.recipientsand.xenv.keys.ageare committed -
Recipient clones the repo and runs
xenv unlock -
age -d -i ~/.ssh/id_ed25519 .xenv.keys.age > .xenv.keys -
They now have
.xenv.keyslocally (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 @envxenv rotate @env
This ensures .xenv.keys.age stays in sync.
revocation
xenv lock --remove <user> must:
- Remove them from
.xenv.recipients - Rotate ALL symmetric keys (they had access)
- Re-encrypt all vaults with new keys
- Re-lock
.xenv.keysfor remaining recipients - 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 logicsrc/cli.ts— add lock/unlock commandssrc/args.ts— add "lock", "unlock" to COMMANDSsrc/doctor.ts— check foragebinary when.xenv.recipientsexistssrc/init.ts— add.xenv.keys.ageto gitignore if lock is used (actually:.xenv.keys.ageshould be committed, not gitignored)src/vault.ts—runKeygenandrotate_vault_keycall re-lock when.xenv.recipientsexistssrc/mcp.ts— addlock_add,lock_remove,lock_list,unlocktools
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.ageand.xenv.recipientschanges (same as reviewing any security-sensitive file) xenv unlockcould 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) agehandlesssh-agenttransparently for passphrase-protected keys
what stays the same
- Solo developers: zero changes.
.xenv.keysworks exactly as today. - CI/production: still uses
XENV_KEY_*env vars. Lock/unlock is for humans only. @xenv/coreSDK: unchanged. Reads.xenv.keyswhich was already decrypted.- MCP server: existing tools unchanged. New lock/unlock tools added.
~/.xenv.keysglobal 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from ahoward/xenv
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
-
bug
Difficulty 1/5 1-3 hours Newbie friendliness 62/100
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
richardcase/skills#26 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Maintainers usually reply within 1 day
-
cloud_provider installs the amd64 cloud-provider-kind binary on arm64 runnersPossibly taken @RaphaelFakhri claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
helm/kind-action#173 ·
-
area:docs phase:release priority:p0 type:test
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
-
premises-missing
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
WingedGuardian/GENesis-AGI#3917 ·
Maintainers usually reply within 1 day