git-vault-store
v0.1.2
Published
Per-project secrets, ssh-key encrypted, committed to the repo and shared via git
Readme
git-vault
Per-project secrets, encrypted with your team's SSH keys, stored on the repo's default branch and shared through git. No external secret store, no extra service — the secrets travel with the repository, encrypted, and only the people whose SSH public keys you list can read them.
Mental model
your-project/ ← any git repo
.git-vault/ ← lives ONLY on the default branch (main/master)
dev/
alice.pub ← ssh-ed25519 recipient public key (one file each)
bob.pub
DB_PASS.vault ← "abc...xyz" preview + age ciphertext for all keys
API_KEY.vault
prod/
alice.pub
DB_PASS.vault- The vault lives on the default branch (
main/master) and is read straight from git objects / written via commits to that branch. The working tree is never touched — you can be on any feature branch and the vault still tracksmain. - Each
<NAME>.vaultis encrypted to every recipient.pubin its env, so any listed person can decrypt it with their own SSH key. - Writing a secret only needs the recipient public keys — anyone with the
repo can
add/set. Reading needs your SSH private key. - Environments (
dev,prod,beta, …) are just subfolders, selected by$ENV.
Install
# option 1 — npm (global)
npm install -g git-vault-store
# option 2 — from a clone, symlink into ~/.local/bin (no npm global)
./install.shBoth git vault <cmd> (git's plugin mechanism) and gitv <cmd> (short alias)
work after install. Encryption uses age via the
pure-JS age-encryption package
plus @noble/curves and
@scure/base — no external binaries.
Quick start
cd ~/some/project # any git repo with a main/master branch
# 1. register yourself as a recipient (seeds ~/.ssh/id_ed25519.pub)
git vault init
# 2. create a secret name, then give it a value
git vault add DB_PASS
git vault set DB_PASS=swordfish
# 3. list (masked) and read (full)
git vault show
# project / dev (1 secret)
# DB_PASS swo...ish
git vault show DB_PASS
# swordfish
# 4. invite a teammate (re-encrypts every secret for them)
git vault allow ~/teammate_id_ed25519.pub
# 5. run your app with the secrets injected as $SECRET_<NAME> env vars
# (a secret named DB_PASS becomes $SECRET_DB_PASS)
git vault run -- npm startEvery step commits to the default branch and pushes, so teammates get the change
on their next git vault command (each one git-fetches first).
Commands
| command | what it does |
|---|---|
| init [--env e] | add your ~/.ssh/id_ed25519.pub as a recipient of e (default dev), commit + push |
| allow <key> [--env e] | add a recipient (a .pub path or a literal ssh-ed25519 … string), re-encrypt every secret, commit + push. Requires that you can currently decrypt them all |
| show [--env e] | list secret names with masked previews (abc...xyz). No key needed — previews are stored in clear |
| show NAME [--env e] | print the full value of NAME to stdout (needs your SSH key) |
| add NAME [NAME2 …] [--env e] | create new secret name(s) with an empty value, commit + push |
| set NAME=VALUE [...] [--env e] | set/change the value of existing secret(s), commit + push |
| run [--env e] [--key p] [--] <cmd> [args…] | decrypt this env's secrets, inject each as $SECRET_<NAME>, then exec <cmd> |
| ls | alias for show |
Common flags:
--env <name>— pick the environment. Defaults to$ENV, thendev.--key <path>— SSH private key to decrypt with (show NAME,run,allow). Defaults to$GV_SSH_KEY, then~/.ssh/id_ed25519.
How it works
Storage on the default branch
git vault figures out the default branch from origin/HEAD, then main, then
master, then the current branch. All reads come from that branch's tip
(origin/<branch> when a remote exists, otherwise the local ref); all writes are
committed onto it via git plumbing and pushed — without checking it out. The
sequence for a write is:
read-tree <branch tip> → hash-object (new blob) → update-index →
write-tree → commit-tree -p <branch tip> → push origin <commit>:refs/heads/<branch>If the branch moved underneath you (a racing writer), the push is rejected, git-vault re-fetches and retries. Different secrets never conflict; the same secret is last-writer-wins.
Because nothing is checked out, your working tree and current branch are untouched:
git checkout -b feature/login
git vault set API_KEY=sk-123 # commits to main, pushes
git status # clean — feature/login is unchanged
git vault show API_KEY # sk-123 — read from main, not your branchIf you happen to be on the default branch with a remote, your local branch will sit a few commits behind
origin/<branch>after a write (the vault commits are on the remote). A plaingit pullcatches it up. Your working tree is still never modified by git-vault.
Encryption
Secrets are encrypted with age. age's JS
implementation has no native SSH support, so git-vault converts SSH Ed25519
keys to age keys using the same mapping as age's agessh:
- public key → age recipient (
age1…) via the Edwards→Montgomery birational map, - private key → age identity (
AGE-SECRET-KEY-1…) viaSHA-512(seed)[:32].
A .vault file is a single age file with one recipient stanza per .pub, so all
listed recipients can decrypt it. The first line of the file is a masked preview
stored in clear, so show can list secrets without decrypting. The preview is
context-aware:
- a URL
scheme://user:password@host/…reveals everything except the password:mongodb+srv://parser_db:***@cluster1.mongodb.net/?x=1; - a prefixed key (
sk-…,ghp_…) keeps the prefix and masks the body:sk-abc...nop; - anything else shows
first3...last3(abc...xyz),***for short values, or(empty).
Access model
- add / set need only the recipient public keys → anyone with the repo can write or rotate a value. (To overwrite a value you don't even need to read the old one.)
- show NAME / run decrypt with your SSH private key → only recipients can read.
- allow must re-encrypt every existing secret for the enlarged recipient set,
which means it first decrypts them all with your key. So you can only invite
someone if you yourself currently have full read access to that env. If you
can't decrypt everything,
allowaborts before changing anything.
Coherent groups & a shared secrets repo
If the project carries a .git-coherent marker (from the companion git-coherent
tool), git-vault treats the listed/discovered sibling repos as one group:
- recipient keys are the union of
.git-vault/<env>/*.pubacross every member (soallowin any member lets the others encrypt to that key); showandrunread secrets from every member, so an app repo can use secrets that live in a dedicated secrets repo. The current repo wins on a name collision, then members in sorted order;add/set/allowstill act on the current repo only — that repo must have its own vault (git vault initthere first). This is why secrets are managed in the secrets repo, not in the app.
This enables the dedicated-secrets-repo pattern:
~/work/
my-secrets/ ← git vault init / add / set here (.git-vault/dev/…)
my-app/ ← .git-coherent lists "my-secrets"
git vault show / run here reads my-secrets' secrets# my-app/.git-coherent
my-secretsBy default every sibling with its own .git-coherent marker is scanned. When you
list folders in the marker (one or more per line, or members = a, b), only
those are used — and a listed folder is opted in by the listing alone, so it does
not need a .git-coherent marker of its own (a dedicated secrets repo
usually won't have one). Listed folders that are absent are skipped, not required.
show/runread each member from its own default branch's local ref; git only fetches the current repo. If a teammate updated the remote secrets repo,git -C ../my-secrets pull(or anygit vaultcommand run inside it) refreshes it.
Environments
$ENV selects the environment for every command; it defaults to dev.
git vault add DB_PASS # dev
git vault set DB_PASS=dev-secret # dev
ENV=prod git vault add DB_PASS # separate prod entry
ENV=prod git vault set DB_PASS=prod-secret
ENV=prod git vault run -- ./server # runs with prod secrets--env overrides $ENV per invocation: git vault show --env prod.
Requirements & limitations
- Ed25519 SSH keys only. RSA keys are not supported (age's SSH mapping is
Ed25519-only). Generate one with
ssh-keygen -t ed25519if needed. - Unencrypted private keys. A passphrase-protected key can't be read directly;
git-vault tells you to remove the passphrase (
ssh-keygen -p -f <key>) or use an unencrypted key. (ssh-agent support is not implemented.) - Last-writer-wins. Two people setting the same secret concurrently: the last push wins. Different secrets never collide.
- The masked preview is intentionally stored in clear, so anyone with repo access
sees a hint: usually
first3...last3, but for a URL the whole thing except the password (host, user, db, query), and for a prefixed key the prefix plusfirst3...last3of the body. - The project must be a git repository with a default branch.
License
MIT
