Git your host can't read.
End-to-end encrypted Git remotes. Keep using clone, pull, and push;
GitHub, a NAS, or any other storage only ever holds ciphertext.
Quick start · How it compares · Design · Specification
Warning
This is an early prototype. The repository format is unstable and may change without a migration path. Do not use it as your only copy of anything: keep an independent copy of every repository and every key.
A private repository is private from other people, not from the host. Even with encryption at rest, the host keeps the key next to the data, so whoever breaks into the host, its storage, or its backups can read your code. End-to-end encryption keeps the key on your devices: an intruder finds only locked boxes, and anything they change is rejected.
- Good fits: notes and journals, unpublished research, client or NDA work, private configuration, backups on storage you do not control.
- What you give up: no web diffs, pull-request reviews, code search, or hosted CI on the plaintext, and nobody can recover your data if you lose every key.
Encrypting Git is not new. File-level tools such as
git-crypt encrypt selected files, and
git-remote-gcrypt has
encrypted whole repositories with GnuPG for years. git-remote-e2ee hides the
whole repository like gcrypt, and fixes what makes that painful to live with:
- Fast: pushes upload only what changed. A push uploads the new Git pack plus a few KB of metadata on every backend: about 9 KB for a one-line change in the benchmark. With a Git or SFTP backend such as GitHub, gcrypt re-uploads the entire encrypted history on every push: 921 MB for the same change.
- Safe: no silent force pushes. Fast-forward checks run on the client, and
the storage moves
HEADonly by compare-and-swap. When two people push at once, one wins and the other gets an ordinary rejection. With gcrypt every push is effectively a force push, so a push made without pulling first can erase someone else's work. - No master key: nothing long-lived is shared. git-crypt protects a
repository with one symmetric key that every collaborator ends up holding,
and it cannot revoke anyone.
git-remote-e2eecreates a fresh random key for every push and wraps it separately for each authorized device's own key. An administrator can revoke one device without rewriting history, and it receives nothing built on top of that revocation.
It also does the rest of what an encrypted remote should:
- Hides who has access. Device keys, roles, and membership changes are encrypted. Storage sees only a padded device count, rounded up to a power of two.
- Read, write, and admin roles. Give a CI runner or a reviewer a read-only key. Only administrators can add or revoke devices.
- Detects tampering, rollback, and forks. A clone that has synced before
refuses an older or diverging state, and
git-e2ee recoverlets a writer continue explicitly after someone corruptsHEAD. - Behaves like Git. Branches, tags, remote branch deletion, and explicit force pushes all work, and the remote grows only by what you add: 878 MiB after five pushes, against 5.2 GiB for gcrypt.
- Stays light. A small push needs about 9 MiB of memory and takes about a second.
- Needs nothing else. Two Rust binaries with all cryptography built in, no GnuPG or OpenSSL. Storage can be any Git host (GitHub, GitLab, a bare repository) or a local or mounted directory.
Under the hood, Git runs on your machine as usual. The helper encrypts the
packs and refs Git hands it, uploads them as opaque immutable objects, and then
atomically moves one opaque HEAD pointer.
Godot repository (867 MiB of history), a local bare repository as the Git
host, Apple M1 Pro, median of three runs. Bold marks where git-remote-e2ee
beats git-remote-gcrypt. Full results in Performance.
| Plain Git | git-remote-gcrypt |
git-remote-e2ee |
|
|---|---|---|---|
| Initial push | 28.4 s | 36.6 s | 22.9 s |
| Fresh fetch | 27.7 s | 55.0 s | 40.0 s |
| Tiny push | 0.08 s | 6.5 s | 0.97 s |
| Tiny update (fetch) | 0.07 s | 22.6 s | 0.77 s |
| Data sent per tiny push | 4.5 KB | 921 MB | 8.9 KB |
Install from source (a Rust toolchain is required). This installs both
git-e2ee, the setup CLI, and git-remote-e2ee, the helper Git calls for
e2ee:: URLs. Both must be on PATH.
cargo install --git https://github.com/combinatrix-ai/git-remote-e2eeUse an empty GitHub repository as encrypted storage and push an existing project to it:
git-e2ee keygen --output ~/.config/git-e2ee/notes.key.json
git-e2ee carrier-init \
--remote https://github.com/you/notes-encrypted.git \
--key ~/.config/git-e2ee/notes.key.json
cd my-notes
git remote add private 'e2ee::git+https://github.com/you/notes-encrypted.git'
git config remote.private.e2ee-key ~/.config/git-e2ee/notes.key.json
git push -u private mainFrom then on, git pull and git push work as usual. The key file stays on
your machine; never commit it, and keep a backup somewhere safe.
To use a second machine, give it its own key and authorize its public half. The user guide walks through it, as well as the directory backend, cloning, and revoking devices.
There are two kinds of encrypted-Git tools. File-level tools, represented
here by git-crypt, encrypt the contents
of selected files inside an otherwise normal repository. Encrypted remotes,
git-remote-gcrypt and
git-remote-e2ee, encrypt the whole repository, history included.
| Private repo | git-crypt |
git-remote-gcrypt |
git-remote-e2ee |
|
|---|---|---|---|---|
| Contents of files you chose to protect | visible | hidden | hidden | hidden |
| Contents of all other files | visible | visible | hidden | hidden |
| File names and directory layout | visible | visible | hidden | hidden |
| Commit messages, authors, and dates | visible | visible | hidden | hidden |
| Branch names and the commit graph | visible | visible | hidden | hidden |
| Which files changed, their sizes, identical files | visible | visible¹ | hidden | hidden |
| When you push, and roughly how much | visible | visible | visible | visible |
| Collaborators | account list | key fingerprints² | count only³ | count only⁴ |
git-crypt |
git-remote-gcrypt |
git-remote-e2ee |
|
|---|---|---|---|
Normal clone, pull, and push |
○ | ○ | ○ |
| GitHub or GitLab as storage | ○ | ○ | ○ |
| Hosted diffs, review, and search for unencrypted parts | ○ | × | × |
| Encrypt only selected files | ○ | × | × |
| Push uploads only new data | △⁵ | △⁶ | ○ |
| Concurrent pushes can't silently overwrite each other | ○ | ×⁷ | ○ |
| Tampering detected | △⁸ | ○ | ○ |
| No master key | ×⁹ | ○ | ○ |
| Revoke one collaborator | ×¹⁰ | ×¹¹ | ○¹² |
| Roles (read / write / admin) | × | ×¹³ | ○ |
| Detects a rolled-back or forked remote | × | × | △¹⁴ |
| No external dependencies | △¹⁵ | ×¹⁶ | ○ |
| Tags and remote branch deletion | ○ | ○ | ○ |
| Mature, stable format | ○ | ○ | × |
| Cryptography | AES-256-CTR, HMAC-SHA1 SIV | OpenPGP (GnuPG) | XChaCha20-Poly1305, HPKE (X25519), Ed25519 |
○ supported · △ with caveats · × not supported
Notes
- git-crypt's README states that it does not hide when a file changes, its length, or whether two files are identical.
- In git-crypt's GPG mode, the repository key is encrypted to each user and
committed under
.git-crypt/, named by key fingerprint. - gcrypt hides recipient key IDs by default (
gpg -R); the number of encrypted-key packets is still observable. - The envelope count is padded to the next power of two, with a minimum of four, so storage sees only an upper bound on the number of devices. Public keys, roles, and membership changes are encrypted, though size patterns may suggest a membership change. Do not reuse device keys between repositories.
- A changed encrypted file becomes a new, unrelated ciphertext blob, so Git cannot delta-compress it against the previous version.
- gcrypt is incremental with its local and rsync backends, but its documentation says a Git or SFTP backend uploads the entire history on every push, and it may repack the remote without warning.
- Every gcrypt push is effectively a force push. Its explicit-force option prevents accidents but does not coordinate concurrent writers.
- git-crypt authenticates encrypted file contents; file names, history, and which files are encrypted are ordinary Git data.
- A master key here means one long-lived key that every collaborator ends up holding. git-crypt uses one symmetric repository key for the life of the repository: GPG mode wraps that same key to each user's GPG key, and without GPG everyone shares the same exported key file. gcrypt and git-remote-e2ee encrypt each push under fresh keys delivered to each recipient's own key.
- git-crypt's README states that it does not support revoking access.
- A participant can be dropped from
gcrypt.participants, but gcrypt does not document a revocation workflow. - Revocation publishes a fresh key without rewriting history. It cannot take back data the device already had.
- gcrypt's recipient list is the local
gcrypt.participantssetting of whoever pushes, so any participant who can push decides who can read the next state. - A clone that has synced before rejects rollback or a diverging history. A brand-new clone cannot tell without an external anchor; see Security model.
- git-crypt is a C++ program linked against OpenSSL; GPG mode also needs GnuPG.
- gcrypt is a shell script that requires GnuPG.
- git-remote-gcrypt: you want an established tool with a stable format, already use GPG, and work alone or can coordinate pushes.
- A file-level tool such as git-crypt: only a few files are secret, and you want GitHub or GitLab to keep working normally for the rest.
- An ordinary private repository: you trust the host and want pull requests, search, previews, and CI.
The storage provider is treated as malicious. git-remote-e2ee aims to
guarantee:
- confidentiality of inner contents and Git metadata: files, paths, refs, object IDs, authors, and messages;
- authenticity and integrity of every fetched state: only devices with write authority under the authenticated policy history can author a state that conforming clients accept;
- continuity for a clone that has synced before: it rejects any state older than, or diverging from, its continuity floor, the newest state it accepted after a complete connectivity check or its own successful push;
- that a revoked device gets no keys for generations that conforming writers build on top of its revocation.
Write authority is about what clients accept, not about who can put bytes on
the storage. Anyone who can write to the storage, including a read-only member
or someone with only push access to the carrier repository, can still replay
old valid states, hide updates, or corrupt HEAD. Clients reject what they
cannot verify, so this can deny service but cannot make unauthorized history
accepted. Restrict storage write access as a separate layer, for example with
read-only repository permissions and branch protection on GitHub.
It does not prevent:
- the host or anyone with storage write access deleting data, corrupting
HEAD, or refusing service; - a brand-new clone, or a clone that has not synced since, being shown an old or forked history, or different clients being shown different histories (that needs an external anchor, which is on the roadmap);
- a revoked writer or administrator creating a valid-looking fork from before its revocation, which clients that never saw the revocation cannot tell apart;
- an authorized writer making destructive changes;
- a revoked device reading what it could read before revocation;
- traffic analysis: update times, object sizes, total growth, and the padded reader count are visible;
- future quantum attacks: key exchange uses X25519.
Concurrent pushes cannot silently overwrite each other as long as the storage honors compare-and-swap, as a Git host's fast-forward check or the directory backend's lock does.
Keys work as a backward chain. The current key can decrypt all earlier history, which lets a new device read the whole repository, but an old key cannot decrypt anything newer. This is key regression, not forward secrecy for history already published. See docs/design.md and docs/spec.md for the full threat model, and SECURITY.md to report an issue.
Committing is ordinary Git: encryption happens only when you push or fetch. Small pushes and fetches upload or download only the change, even when GitHub or another Git host is the storage.
Measured on the Godot repository (867 MiB of history), with a local bare
repository standing in for the Git host, on an Apple M1 Pro with 32 GiB RAM.
Median of three runs; tiny rows are a one-line change, repeated five times.
Bold marks where git-remote-e2ee beats git-remote-gcrypt.
| Plain Git | git-remote-gcrypt |
git-remote-e2ee |
|
|---|---|---|---|
| Initial encryption (to a local directory) | 28.6 s | 11.0 s | 8.1 s |
| Initial push | 28.4 s | 36.6 s | 22.9 s |
| Fresh fetch | 27.7 s | 55.0 s | 40.0 s |
| Tiny commit | 0.09 s | 0.09 s | 0.09 s |
| Tiny push | 0.08 s | 6.5 s | 0.97 s |
| Tiny update (fetch) | 0.07 s | 22.6 s | 0.77 s |
| Data sent per tiny push | 4.5 KB | 921 MB | 8.9 KB |
| Peak memory, initial push | 1.27 GiB | 0.98 GiB | 1.27 GiB |
| Peak memory, tiny push | 37 MiB | 887 MiB | 9 MiB |
| Remote size after five tiny pushes | 891 MiB | 5,272 MiB | 878 MiB |
Client disk after fresh fetch (.git plus cache) |
891 MiB | 1,780 MiB | 1,779 MiB |
With a Git backend, gcrypt sends the whole encrypted history again on every
push, so the remote grows by about the repository size each time.
git-remote-e2ee sends the new pack plus a few KB of metadata. On the client,
git-remote-e2ee keeps a local cache of the encrypted carrier next to your
repository, which roughly doubles disk use, the same as gcrypt's local copy.
Peak memory for full transfers is dominated by Git's own pack generation. Plain Git
stays faster on small operations because a Git server understands the
repository; an encrypted remote has to verify and decrypt on the client. These
are local measurements without network latency.
To reproduce these numbers, run scripts/reproduce-benchmark.sh from a clone
of this repository on macOS or Linux. It fetches the pinned inputs (about
1 GB), runs three rounds, and writes this table plus raw data and environment
details to bench-results/. Expect a few hours and about 12 GB of free disk.
docs/benchmarks.md has the method, a Linux aarch64
cross-check, per-push series, phase breakdown, and caveats.
What does the GitHub repository look like?
One branch, git-remote-e2ee, holding ciphertext under e2ee/. It can live
next to unrelated branches. Do not edit it by hand.
What if I lose my key?
If no remaining device has access, the data is gone. There is no recovery
service by design. Keep an offline backup of at least one administrator key and
its .admin-state.json file.
Does revoking a device hide old history from it? No. It keeps whatever it could already decrypt. It gets no keys for anything published on top of the revocation. A writer who never saw the revocation, or was shown a stale state, could still publish to the old reader set; see Security model.
Can I review pull requests? Not on the host. Review happens on a machine that has a key: fetch the branch and diff locally, or give a CI runner its own read-only device key.
What if someone corrupts HEAD?
Clients refuse it and stop. A writer can then run git-e2ee recover, which
shows what it found and continues from the last verified state, or from a newer
legitimate state it discovers in the carrier history. See the
user guide.
Can the host roll my repository back? It can serve old data. A clone that has synced before refuses anything older than, or diverging from, what it already accepted. A fresh clone, or a clone that has not synced since a newer state was published, cannot tell yet.
Working today: clone, fetch, pull, and push, including tags and remote branch
deletion; the directory and Git-host backends; incremental pushes and fetches
(the Git-host backend keeps a local cache of the carrier); per-device keys with
read, write, and admin roles; adding and revoking devices; rollback and race
detection; and explicit recovery from a corrupted HEAD.
Planned: M-of-N administrator approval, key recovery and replacement, automatic retry after losing a push race, an S3 conditional-write backend, garbage collection and compaction, shallow and partial clone, optional transparency-log anchoring, and an optional file-level mode that encrypts only selected files while keeping per-device keys and revocation.
- User guide: backends, cloning, device management, and the storage layout
- docs/design.md: design overview and threat model
- docs/spec.md: normative protocol specification
- docs/benchmarks.md: benchmark method and results
- docs/durability.md: crash and power-loss guarantees of the filesystem backend
- CONTRIBUTING.md: building, testing, and what the test suite covers
Licensed under either of the Apache License, Version 2.0 or the MIT License, at your option.