git-sshripped
git-sshripped keeps selected files encrypted in Git while still letting you work with plaintext when the repository is unlocked.
It is for teams that already use SSH keys and want encryption to fit normal Git usage instead of adding a separate manual workflow.
Why this tool
- SSH-native recipient access using existing SSH public/private keys.
- Git-transparent encryption through Git filters, so normal
git add,git commit, andgit checkoutbehavior still applies. - Movable encrypted files by default, with opt-in path binding for patterns that should reject ciphertext moved from another path.
- Worktree-aware lock/unlock state that behaves consistently across Git worktrees.
- Built-in checks (
doctor,verify --strict) to catch config issues and plaintext mistakes early.
Many alternatives fall short because they require manual encrypt/decrypt steps, do not use SSH as the recipient model, or do not behave cleanly with worktrees.
How it works
initsets up manifest, patterns, and Git filter wiring.- Protected paths (for example
secrets/**) are encrypted when staged. - The repository data key is wrapped to configured SSH recipients.
unlockmakes protected files readable in your working tree.lockremoves unlocked key material and protected paths read back as ciphertext.
Quick start
Install via npm (no Rust required)
npm install git-sshripped
To auto-unlock encrypted files after npm install, add a postinstall script to your project:
{
"scripts": {
"postinstall": "git-sshripped unlock --soft"
}
}
The --soft flag makes unlock non-fatal so npm install succeeds even when the user doesn't have access to encrypted files.
Install from source
cargo install git_sshripped_cli
Initialize a repository
# in an existing Git repository
git-sshripped init --strict --pattern "secrets/**" --recipient-key ~/.ssh/id_ed25519.pub
# use --path-binding strict for patterns that should reject encrypted blob moves
# git-sshripped init --pattern "prod-secrets/**" --path-binding strict --recipient-key ~/.ssh/id_ed25519.pub
# unlock using your SSH private key
git-sshripped unlock --identity ~/.ssh/id_ed25519
# work normally
mkdir -p secrets
printf 'API_TOKEN=example\n' > secrets/app.env
git add secrets/app.env
# validate setup
git-sshripped doctor
git-sshripped verify --strict
# lock when done
git-sshripped lock
Common commands
Daily use
git-sshripped unlock [--identity <path>] [--github-user <user>] [--prefer-agent] [--no-agent]
unlock auto-resolves an agent helper in this order:
GSC_SSH_AGENT_HELPERenv vargit config --local git-sshripped.agentHelper.git-sshripped/config.toml(agent_helper)- PATH search (
git-sshripped-agent-helper,age-plugin-ssh-agent,age-plugin-ssh)
Helper contract: <helper> <wrapped-key-file> -> stdout with decrypted 32-byte
repo key (raw bytes or 64-char hex).
git-sshripped lockgit-sshripped statusgit-sshripped doctor [--json]git-sshripped verify [--strict] [--json]
User and access management
git-sshripped add-user --key <pub|path>git-sshripped add-user --github-user <user>git-sshripped add-user --github-keys-url <url>git-sshripped list-users [--verbose]git-sshripped remove-user --fingerprint <fp> [--force]git-sshripped revoke-user --fingerprint <fp> [--auto-reencrypt] [--json]git-sshripped revoke-user --github-user <user> [--all-keys-for-user] [--auto-reencrypt] [--json]git-sshripped revoke-user --org <org> --team <team> [--auto-reencrypt] [--json]git-sshripped add-github-user --username <user> [--all] [--key <pub-key>] [--key-file <path>] [--no-auto-wrap]git-sshripped list-github-users [--verbose]git-sshripped remove-github-user --username <user> [--force]git-sshripped refresh-github-keys [--username <user>] [--dry-run] [--fail-on-drift] [--json]git-sshripped add-github-team --org <org> --team <team> [--no-auto-wrap]git-sshripped list-github-teamsgit-sshripped remove-github-team --org <org> --team <team>git-sshripped refresh-github-teams [--org <org>] [--team <team>] [--dry-run] [--fail-on-drift] [--json]git-sshripped access-audit [--identity <path>] [--json]
add-github-user and add-github-team auto-wrap by default when an unlock session is available. Use --no-auto-wrap to skip wrapping.
add-github-user filters fetched keys to only those matching a local private key in ~/.ssh/ by default. Pass --all to add every key associated with the GitHub account.
init can be run without recipients. The repo key is stored in a local session until a recipient is added (e.g. via add-github-user).
Movable files and path binding
New encrypted files are movable by default: ciphertext can be checked out at a new path and still decrypt. This keeps normal Git file moves from breaking checkout, rebase, or branch switching.
For patterns where encrypted blobs must not move between paths, opt into path binding:
git-sshripped init --pattern "prod-secrets/**" --path-binding strict
or set the attribute manually:
prod-secrets/** filter=git-sshripped diff=git-sshripped git-sshripped-path-binding=strict
Path-bound files use the legacy AES-SIV path-bound format. Movable files use the movable AES-SIV format. Existing repositories initialized before movable mode was the default can migrate by flipping the manifest default for future writes and re-encrypting the files already tracked at HEAD:
git-sshripped unlock
git-sshripped policy set --default-path-binding none
git-sshripped reencrypt # one-time retrofit: re-clean tracked protected files with the new default
git commit -m "Switch to movable encryption"
The policy set step only changes the algorithm chosen for new clean-filter
passes; reencrypt is what actually rewrites the currently-tracked ciphertext.
History blobs stay in their original format, which is fine -- decrypt always
dispatches on the per-blob algorithm ID.
Use --default-path-binding strict to make future writes path-bound unless a
pattern-level attribute overrides it.
Maintenance
git-sshripped installgit-sshripped rewrapgit-sshripped rotate-key [--auto-reencrypt]git-sshripped reencryptgit-sshripped migrate-from-git-crypt [--dry-run] [--reencrypt] [--verify] [--json]git-sshripped migrate-from-git-crypt ... [--write-report <path>]git-sshripped export-repo-key --out <path>git-sshripped import-repo-key --input <path>git-sshripped policy show|set|verify [--json]git-sshripped policy set --require-verify-strict-clean-for-rotate-revoke <true|false>git-sshripped policy set --max-source-staleness-hours <hours>git-sshripped policy set --default-path-binding <none|strict>git-sshripped config set-agent-helper <path>git-sshripped config set-github-api-base <url>git-sshripped config set-github-web-base <url>git-sshripped config set-github-auth-mode <auto|gh|token|anonymous>git-sshripped config set-github-private-source-hard-fail <true|false>git-sshripped config show
Debugging performance
git-sshripped ships optional span-level timing via the
profiling crate with a
tracing-backed subscriber. When disabled (the default) the instrumentation
compiles to no-ops and adds zero runtime cost; enable it only when you need
to investigate where time goes during a command.
Enable tracing
Build a release binary with the profile-trace feature:
cargo build --release --features git_sshripped_cli/profile-trace
Then run any subcommand with GIT_SSHRIPPED_TRACE=1 set. Span entries and
their durations are written to stderr:
GIT_SSHRIPPED_TRACE=1 ./target/release/git-sshripped unlock --soft 2>trace.log
RUST_LOG is respected if you want to narrow which spans are emitted (for
example RUST_LOG='info').
Run the benchmarks
The packages/cli/benches/unlock.rs harness uses
criterion to measure the real-world
cost of unlock --soft against a live repository. Real-repo benches are
opt-in via environment variables so CI stays cheap:
export BENCH_REPO=$HOME/GitHub/monorepo # an already-unlocked sshripped repo
export GIT_SSHRIPPED_BIN=$PWD/target/release/git-sshripped
cargo bench -p git_sshripped_cli --features profile-trace
Benches that require a missing variable print skipping and return, so it
is safe to run the harness without setting them.
Security notes
- This project is pre-1.0 and should be treated as security-sensitive software.
- Deterministic encryption is used for Git filter stability and has known leakage tradeoffs.
- Keep at least two valid recipients configured to reduce lockout risk.
git diff/git log -pfor protected files. git-sshripped registers a textconv driver sogit diff,git log -p,git show, andgit blameshow plaintext for unlocked repos. Git's textconv contract does not pass the original repo path to the textconv command (only a temp file), so path-bound (AesSivV1) ciphertext can't be decrypted from the temp file alone. The textconv reverse-resolves the path from the blob hash viagit rev-list --objects --allandgit ls-files -s, cached at<git-dir>/git-sshripped/textconv-paths.cacheand invalidated when HEAD moves. The cache contains only public information (object IDs and repo paths) — never plaintext or key material. git-sshripped explicitly setsdiff.git-sshripped.cachetextconv = falseininstall_git_filtersbecause git'scachetextconvwould persist the textconv plaintext output in.git/objects/info/cache, leaking decrypted secrets pastgit-sshripped lock. Locked repos pass the raw ciphertext through with a warning rather than aborting the surrounding Git command.
See SECURITY.md for the full threat model and operational guidance.
See docs/COMPATIBILITY.md for git-crypt command mapping and migration notes.