From 6d40722b301f582777b248ae194c115f40c7c69e Mon Sep 17 00:00:00 2001 From: hmirin Date: Wed, 14 Oct 2026 15:24:00 +0900 Subject: [PATCH 1/7] Trim the README and move detail into docs State the benchmark numbers once, reach Quick start sooner, and add a clone example. Keep a short comparison table and security summary in the README; the full comparison with footnotes and the full security model move to docs/comparison.md and docs/security-model.md. --- README.md | 243 ++++++++++------------------------------- SECURITY.md | 2 +- docs/comparison.md | 99 +++++++++++++++++ docs/security-model.md | 49 +++++++++ 4 files changed, 209 insertions(+), 184 deletions(-) create mode 100644 docs/comparison.md create mode 100644 docs/security-model.md diff --git a/README.md b/README.md index d7011b9..bdf7b57 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,10 @@ Encrypting Git is not new. File-level tools such as 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](#performance). 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. +- **Fast: pushes upload only what changed.** A one-line change sends about + 9 KB on every backend. 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 in the [benchmark](#performance). - **Safe: no silent force pushes.** Fast-forward checks run on the client, and the storage moves `HEAD` only by compare-and-swap. When two people push at once, one wins and the other gets an ordinary rejection. With gcrypt every @@ -68,46 +67,19 @@ whole repository like gcrypt, and fixes what makes that painful to live with: repository with one symmetric key that every collaborator ends up holding, and it cannot revoke anyone. `git-remote-e2ee` creates 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 recover` lets a writer - continue explicitly after someone corrupts `HEAD`. -- **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. + administrator can revoke one device without rewriting history, and the + revoked device gets no keys for anything pushed after that. + +It also hides who has access (storage sees only a padded device count), gives +devices read, write, or admin roles, detects tampering and rollback, supports +branches, tags, and explicit force pushes, and ships as two Rust binaries with +all cryptography built in. 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. -### At a glance - -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](#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** | - ## Quick start Install from source (a Rust toolchain is required). This installs both @@ -133,159 +105,66 @@ git config remote.private.e2ee-key ~/.config/git-e2ee/notes.key.json git push -u private main ``` -From 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. +From then on, `git pull` and `git push` work as usual. To clone it somewhere +else, pass a key once: + +```console +git -c e2ee.key="$HOME/.config/git-e2ee/notes.key.json" clone \ + 'e2ee::git+https://github.com/you/notes-encrypted.git' my-notes +``` -To use a second machine, give it its own key and authorize its public half. -The [user guide](docs/guide.md#add-a-second-device) walks through it, as well as -the directory backend, cloning, and revoking devices. +The key file stays on your machine; never commit it, and keep a backup +somewhere safe. Each additional machine should get its own key rather than a +copy of this one. The [user guide](docs/guide.md#add-a-second-device) walks +through adding and revoking devices, read-only roles, and the directory +backend. ## How it compares -There are two kinds of encrypted-Git tools. **File-level tools**, represented -here by [`git-crypt`](https://github.com/AGWA/git-crypt), encrypt the contents -of selected files inside an otherwise normal repository. **Encrypted remotes**, -[`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt) and +**File-level tools** such as git-crypt encrypt selected files inside an +otherwise normal repository. **Encrypted remotes**, git-remote-gcrypt and `git-remote-e2ee`, encrypt the whole repository, history included. -### What the storage host can learn - | | 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⁴ | - -### What each tool can do - -| | `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 - -1. git-crypt's README states that it does not hide when a file changes, its - length, or whether two files are identical. -2. In git-crypt's GPG mode, the repository key is encrypted to each user and - committed under `.git-crypt/`, named by key fingerprint. -3. gcrypt hides recipient key IDs by default (`gpg -R`); the number of - encrypted-key packets is still observable. -4. 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. -5. A changed encrypted file becomes a new, unrelated ciphertext blob, so Git - cannot delta-compress it against the previous version. -6. gcrypt is incremental with its local and rsync backends, but its - [documentation](https://manpages.debian.org/trixie/git-remote-gcrypt/git-remote-gcrypt.1.en.html) - says a Git or SFTP backend uploads the entire history on every push, and it - may repack the remote without warning. -7. Every gcrypt push is effectively a force push. Its explicit-force option - prevents accidents but does not coordinate concurrent writers. -8. git-crypt authenticates encrypted file contents; file names, history, and - which files are encrypted are ordinary Git data. -9. 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. -10. git-crypt's README states that it does not support revoking access. -11. A participant can be dropped from `gcrypt.participants`, but gcrypt does - not document a revocation workflow. -12. Revocation publishes a fresh key without rewriting history. It cannot take - back data the device already had. -13. gcrypt's recipient list is the local `gcrypt.participants` setting of - whoever pushes, so any participant who can push decides who can read the - next state. -14. 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](#security-model). -15. git-crypt is a C++ program linked against OpenSSL; GPG mode also needs - GnuPG. -16. gcrypt is a shell script that requires GnuPG. - -
- -### When another tool is a better fit +| Host sees file contents | yes | unprotected files only | no | no | +| Host sees file names, commits, branches | yes | yes | no | no | +| Hosted diffs, review, and search | ○ | ○ for unprotected files | × | × | +| Push uploads only new data | ○ | △ | △ | ○ | +| Concurrent pushes can't silently overwrite each other | ○ | ○ | × | ○ | +| No key shared by every collaborator | ○ | × | ○ | ○ | +| Revoke one collaborator | ○ | × | × | ○ | +| Read / write / admin roles | ○ | × | × | ○ | +| Mature, stable format | ○ | ○ | ○ | × | + +○ yes · △ with caveats · × no. The [full comparison](docs/comparison.md) has +every row, the reasoning behind each △ and ×, and the cryptography each tool +uses. + +Pick something else when it fits better: - **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. +- **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. ## Security model -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](docs/design.md) and [docs/spec.md](docs/spec.md) -for the full threat model, and [SECURITY.md](SECURITY.md) to report an issue. +The storage provider is treated as malicious. `git-remote-e2ee` keeps file +contents and Git metadata (paths, refs, authors, messages) confidential, +rejects any state not signed by a device with write authority, and makes a +clone that has synced before refuse older or diverging history. + +It does not stop the host, or anyone with write access to the storage, from +deleting data or refusing service. A brand-new clone cannot yet detect being +shown an old or forked history, a revoked device keeps what it could already +read, and update times and sizes stay visible. Restrict storage write access +separately, for example with branch protection on GitHub. + +[docs/security-model.md](docs/security-model.md) lists the exact guarantees and +limits. [SECURITY.md](SECURITY.md) explains how to report an issue. ## Performance @@ -344,7 +223,7 @@ its `.admin-state.json` file. 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](#security-model). +[docs/security-model.md](docs/security-model.md). **Can I review pull requests?** Not on the host. Review happens on a machine that has a key: fetch the branch @@ -356,11 +235,6 @@ 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](docs/guide.md#recover-from-a-broken-head). -**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. - ## Status and roadmap Working today: clone, fetch, pull, and push, including tags and remote branch @@ -379,6 +253,9 @@ selected files while keeping per-device keys and revocation. - [User guide](docs/guide.md): backends, cloning, device management, and the storage layout +- [docs/comparison.md](docs/comparison.md): full comparison with git-crypt and + git-remote-gcrypt +- [docs/security-model.md](docs/security-model.md): guarantees and limits - [docs/design.md](docs/design.md): design overview and threat model - [docs/spec.md](docs/spec.md): normative protocol specification - [docs/benchmarks.md](docs/benchmarks.md): benchmark method and results diff --git a/SECURITY.md b/SECURITY.md index c8ea0bd..292885d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ Please report vulnerabilities privately to `git-remote-e2ee` is an early prototype and has not been independently audited. Its intended guarantees and known limits are listed in the -[README](README.md#security-model) and specified in [docs/design.md](docs/design.md) and +[security model](docs/security-model.md) and specified in [docs/design.md](docs/design.md) and [docs/spec.md](docs/spec.md). Reports are especially welcome about: - plaintext, refs, paths, or other inner metadata reaching storage; diff --git a/docs/comparison.md b/docs/comparison.md new file mode 100644 index 0000000..335f91b --- /dev/null +++ b/docs/comparison.md @@ -0,0 +1,99 @@ +# Comparison with other encrypted-Git tools + +The [README](../README.md#how-it-compares) has the short version. This page +keeps every row and the reasoning behind each mark. Competitor behavior was +checked against each project's own README and manual on 2026-09-30. + +There are two kinds of encrypted-Git tools. **File-level tools**, represented +here by [`git-crypt`](https://github.com/AGWA/git-crypt), encrypt the contents +of selected files inside an otherwise normal repository. **Encrypted remotes**, +[`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt) and +`git-remote-e2ee`, encrypt the whole repository, history included. + +## What the storage host can learn + +| | 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⁴ | + +## What each tool can do + +| | `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 + +1. git-crypt's README states that it does not hide when a file changes, its + length, or whether two files are identical. +2. In git-crypt's GPG mode, the repository key is encrypted to each user and + committed under `.git-crypt/`, named by key fingerprint. +3. gcrypt hides recipient key IDs by default (`gpg -R`); the number of + encrypted-key packets is still observable. +4. 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. +5. A changed encrypted file becomes a new, unrelated ciphertext blob, so Git + cannot delta-compress it against the previous version. +6. gcrypt is incremental with its local and rsync backends, but its + [documentation](https://manpages.debian.org/trixie/git-remote-gcrypt/git-remote-gcrypt.1.en.html) + says a Git or SFTP backend uploads the entire history on every push, and it + may repack the remote without warning. +7. Every gcrypt push is effectively a force push. Its explicit-force option + prevents accidents but does not coordinate concurrent writers. +8. git-crypt authenticates encrypted file contents; file names, history, and + which files are encrypted are ordinary Git data. +9. 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. +10. git-crypt's README states that it does not support revoking access. +11. A participant can be dropped from `gcrypt.participants`, but gcrypt does + not document a revocation workflow. +12. Revocation publishes a fresh key without rewriting history. It cannot take + back data the device already had. +13. gcrypt's recipient list is the local `gcrypt.participants` setting of + whoever pushes, so any participant who can push decides who can read the + next state. +14. 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](security-model.md). +15. git-crypt is a C++ program linked against OpenSSL; GPG mode also needs + GnuPG. +16. gcrypt is a shell script that requires GnuPG. + +## When another tool is a better fit + +- **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. + diff --git a/docs/security-model.md b/docs/security-model.md new file mode 100644 index 0000000..ac3169d --- /dev/null +++ b/docs/security-model.md @@ -0,0 +1,49 @@ +# Security model + +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 [design.md](design.md) and [spec.md](spec.md) +for the full threat model, and [SECURITY.md](../SECURITY.md) to report an issue. + From 5cbdb14fda0d6b447985079c2de766debdd80d33 Mon Sep 17 00:00:00 2001 From: hmirin Date: Wed, 14 Oct 2026 15:50:20 +0900 Subject: [PATCH 2/7] Show the three pillars as one figure and fold Performance into them The fast/safe/no-master-key claims now sit next to their evidence in a single SVG. The standalone Performance table is dropped from the README; docs/benchmarks.md already holds the full results and the reproduce script. --- README.md | 56 ++++++------------------------ docs/art/pillars.svg | 83 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 45 deletions(-) create mode 100644 docs/art/pillars.svg diff --git a/README.md b/README.md index bdf7b57..2d5b872 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,18 @@ Encrypting Git is not new. File-level tools such as 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: a one-line push sends 4.5 KB with plain Git, 921 MB with gcrypt, and 8.9 KB with git-remote-e2ee. Safe: when two devices push at once, one wins and the other is rejected. No master key: every push gets a fresh key for each device, and a revoked device gets none for later pushes. +

+ - **Fast: pushes upload only what changed.** A one-line change sends about - 9 KB on every backend. 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 in the [benchmark](#performance). + 9 KB. With a Git or SFTP backend such as GitHub, gcrypt re-uploads the entire + encrypted history on every push, so the remote grows by about the repository + size each time. Plain Git is still faster on small operations, because an + encrypted remote has to verify and decrypt on the client, and the client + keeps a local cache of the encrypted carrier, which roughly doubles disk use. + [docs/benchmarks.md](docs/benchmarks.md) has every measurement, the method, + and `scripts/reproduce-benchmark.sh` to rerun it. - **Safe: no silent force pushes.** Fast-forward checks run on the client, and the storage moves `HEAD` only by compare-and-swap. When two people push at once, one wins and the other gets an ordinary rejection. With gcrypt every @@ -166,48 +174,6 @@ separately, for example with branch protection on GitHub. [docs/security-model.md](docs/security-model.md) lists the exact guarantees and limits. [SECURITY.md](SECURITY.md) explains how to report an issue. -## Performance - -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](docs/benchmarks.md) has the method, a Linux aarch64 -cross-check, per-push series, phase breakdown, and caveats. - ## FAQ **What does the GitHub repository look like?** diff --git a/docs/art/pillars.svg b/docs/art/pillars.svg new file mode 100644 index 0000000..6c1a3c2 --- /dev/null +++ b/docs/art/pillars.svg @@ -0,0 +1,83 @@ + + Fast, safe, and no master key + Fast: a one-line push sends 4.5 KB with plain Git, 921 MB with git-remote-gcrypt on a Git backend, and 8.9 KB with git-remote-e2ee (log scale). Safe: when two devices push at once, storage accepts one through compare-and-swap and rejects the other, while gcrypt would let the second push erase the first. No master key: every push gets a fresh key wrapped for each device; a revoked device gets no key for later pushes, while git-crypt shares one key with everyone. Icons from Tabler Icons (MIT). + + + + + + + + + + + + Fast + Pushes upload only what changed + Data sent per one-line push + Plain Git + + 4.5 KB + gcrypt + + 921 MB + e2ee + + 8.9 KB + log scale + Push time 0.97 s (gcrypt 6.5 s) + After 5 pushes 878 MiB (gcrypt 5.3 GiB) + + Godot, 867 MiB of history, Git backend. + + + + Safe + No silent force pushes + Device A + + Device B + + + + + + + + + + HEAD + compare-and-swap + One push wins; the other gets an + ordinary rejection and pulls first. + + gcrypt: every push is a force push, + so B can erase A's work. + + + + No master key + A fresh key per push, per device + push 1 + push 2 + push 3 + Laptop + CI (read) + Old phone + + + + + + + + + + + + revoke Old phone + Revoke one device, no history rewrite. + + git-crypt: one repository key that + everyone shares, and no revocation. + From 682550e6ddec4735ddc8ba22caa62700288609d6 Mon Sep 17 00:00:00 2001 From: hmirin Date: Wed, 14 Oct 2026 16:18:58 +0900 Subject: [PATCH 3/7] Drop the remote-growth claim for gcrypt and explain the full upload The 5,272 MiB figure came from a benchmark receiver with receive.unpackLimit=0. With default settings a small gcrypt push is unpacked and deduplicated on the host, so storage need not grow, even though the whole history is still transferred. Show fetch time instead, and say why gcrypt re-uploads: each state is a new parentless commit. --- docs/art/pillars.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/art/pillars.svg b/docs/art/pillars.svg index 6c1a3c2..2527a50 100644 --- a/docs/art/pillars.svg +++ b/docs/art/pillars.svg @@ -26,7 +26,7 @@ 8.9 KB log scale Push time 0.97 s (gcrypt 6.5 s) - After 5 pushes 878 MiB (gcrypt 5.3 GiB) + Fetch time 0.77 s (gcrypt 22.6 s) Godot, 867 MiB of history, Git backend. From c56e66e3ea715883daeb6fd842dc7fc5c0fbd3b3 Mon Sep 17 00:00:00 2001 From: hmirin Date: Wed, 14 Oct 2026 16:19:06 +0900 Subject: [PATCH 4/7] Explain why gcrypt re-uploads the whole history --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2d5b872..95a4e17 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ whole repository like gcrypt, and fixes what makes that painful to live with: - **Fast: pushes upload only what changed.** A one-line change sends about 9 KB. With a Git or SFTP backend such as GitHub, gcrypt re-uploads the entire - encrypted history on every push, so the remote grows by about the repository - size each time. Plain Git is still faster on small operations, because an + encrypted history on every push: it publishes each state as a new parentless + commit, so Git cannot skip the packs the host already has. Plain Git is still faster on small operations, because an encrypted remote has to verify and decrypt on the client, and the client keeps a local cache of the encrypted carrier, which roughly doubles disk use. [docs/benchmarks.md](docs/benchmarks.md) has every measurement, the method, From ac958647a885ed46edd5e2e2ec72403042fc3790 Mon Sep 17 00:00:00 2001 From: hmirin Date: Wed, 14 Oct 2026 22:43:06 +0900 Subject: [PATCH 5/7] Make Fast a benchmark panel in the pillars figure Fast now shows data sent, small-change and whole-history times, and peak memory as bar charts, including where git-remote-e2ee is slower or uses more memory. Safe and No master key sit below it. --- README.md | 2 +- docs/art/pillars.svg | 191 +++++++++++++++++++++++++++---------------- 2 files changed, 120 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 95a4e17..fb2911f 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ 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: a one-line push sends 4.5 KB with plain Git, 921 MB with gcrypt, and 8.9 KB with git-remote-e2ee. Safe: when two devices push at once, one wins and the other is rejected. No master key: every push gets a fresh key for each device, and a revoked device gets none for later pushes. + Fast: a one-line push sends 4.5 KB with plain Git, 921 MB with gcrypt, and 8.9 KB with git-remote-e2ee; bar charts compare push, fetch, and memory on the Godot repository. Safe: when two devices push at once, one wins and the other is rejected. No master key: every push gets a fresh key for each device, and a revoked device gets none for later pushes.

- **Fast: pushes upload only what changed.** A one-line change sends about diff --git a/docs/art/pillars.svg b/docs/art/pillars.svg index 2527a50..aa58870 100644 --- a/docs/art/pillars.svg +++ b/docs/art/pillars.svg @@ -1,6 +1,6 @@ - + Fast, safe, and no master key - Fast: a one-line push sends 4.5 KB with plain Git, 921 MB with git-remote-gcrypt on a Git backend, and 8.9 KB with git-remote-e2ee (log scale). Safe: when two devices push at once, storage accepts one through compare-and-swap and rejects the other, while gcrypt would let the second push erase the first. No master key: every push gets a fresh key wrapped for each device; a revoked device gets no key for later pushes, while git-crypt shares one key with everyone. Icons from Tabler Icons (MIT). + Fast, measured on the Godot repository (867 MiB of history) with a Git backend, median of three runs, comparing plain Git, git-remote-gcrypt, and git-remote-e2ee. Data sent per one-line push: 4.5 KB, 921 MB, 8.9 KB. Small change: push 0.08 s, 6.5 s, 0.97 s; fetch 0.07 s, 22.6 s, 0.77 s. Whole history: initial push 28.4 s, 36.6 s, 22.9 s; fresh fetch 27.7 s, 55.0 s, 40.0 s. Peak memory: small push 37 MiB, 887 MiB, 9 MiB; initial push 1.27 GiB, 0.98 GiB, 1.27 GiB. Safe: when two devices push at once, storage accepts one through compare-and-swap and rejects the other, while gcrypt would let the second push erase the first. No master key: every push gets a fresh key wrapped for each device; a revoked device gets no key for later pushes, while git-crypt shares one key with everyone. Icons from Tabler Icons (MIT). @@ -8,76 +8,123 @@ - - - - + + Fast Pushes upload only what changed - Data sent per one-line push - Plain Git - - 4.5 KB - gcrypt - - 921 MB - e2ee - - 8.9 KB - log scale - Push time 0.97 s (gcrypt 6.5 s) - Fetch time 0.77 s (gcrypt 22.6 s) - - Godot, 867 MiB of history, Git backend. - - - - Safe - No silent force pushes - Device A - - Device B - - - - - - - - - - HEAD - compare-and-swap - One push wins; the other gets an - ordinary rejection and pulls first. - - gcrypt: every push is a force push, - so B can erase A's work. - - - - No master key - A fresh key per push, per device - push 1 - push 2 - push 3 - Laptop - CI (read) - Old phone - - - - - - - - - - - - revoke Old phone - Revoke one device, no history rewrite. - - git-crypt: one repository key that - everyone shares, and no revocation. + Data sent per small push + log scale + One-line change + + 4.5 KB + + 921 MB + + 8.9 KB + gcrypt re-uploads the whole + encrypted history on every push + to a Git host. + + Small change + seconds + Push + + 0.08 s + + 6.5 s + + 0.97 s + Fetch + + 0.07 s + + 22.6 s + + 0.77 s + + Whole history + seconds + Initial push + + 28.4 s + + 36.6 s + + 22.9 s + Fresh fetch + + 27.7 s + + 55.0 s + + 40.0 s + + Peak memory + MiB or GiB + Small push + + 37 MiB + + 887 MiB + + 9 MiB + Initial push + + 1.27 GiB + + 0.98 GiB + + 1.27 GiB + + Plain Git + git-remote-gcrypt + git-remote-e2ee + Godot, 867 MiB of history, Git backend, Apple M1 Pro, median of 3 runs + + Safe + No silent force pushes + Device A + + Device B + + + + + + + + + + HEAD + compare-and-swap + One push wins; the other gets an + ordinary rejection and pulls first. + gcrypt: every push is a force push, + so B can erase A's work. + + + No master key + A fresh key per push, per device + push 1 + push 2 + push 3 + Laptop + CI (read) + Old phone + + + + + + + + + + + + revoke Old phone + Revoke one device, no history rewrite. + git-crypt: one repository key that + everyone shares, and no revocation. + From 739eb9d19ae94a7db3ec39b2d51cab8c03196faf Mon Sep 17 00:00:00 2001 From: hmirin Date: Wed, 14 Oct 2026 22:43:31 +0900 Subject: [PATCH 6/7] Use gray for competitors in the Fast charts so green alone means git-remote-e2ee --- docs/art/pillars.svg | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/art/pillars.svg b/docs/art/pillars.svg index aa58870..c1e5d42 100644 --- a/docs/art/pillars.svg +++ b/docs/art/pillars.svg @@ -15,9 +15,9 @@ Data sent per small push log scale One-line change - + 4.5 KB - + 921 MB 8.9 KB @@ -28,16 +28,16 @@ Small change seconds Push - + 0.08 s - + 6.5 s 0.97 s Fetch - + 0.07 s - + 22.6 s 0.77 s @@ -45,16 +45,16 @@ Whole history seconds Initial push - + 28.4 s - + 36.6 s 22.9 s Fresh fetch - + 27.7 s - + 55.0 s 40.0 s @@ -62,22 +62,22 @@ Peak memory MiB or GiB Small push - + 37 MiB - + 887 MiB 9 MiB Initial push - + 1.27 GiB - + 0.98 GiB 1.27 GiB - Plain Git - git-remote-gcrypt + Plain Git + git-remote-gcrypt git-remote-e2ee Godot, 867 MiB of history, Git backend, Apple M1 Pro, median of 3 runs From 3ae9aa4395af2c060a7cc115b8f8634ddabfef6d Mon Sep 17 00:00:00 2001 From: hmirin Date: Wed, 14 Oct 2026 23:57:34 +0900 Subject: [PATCH 7/7] Drop the remote-size row from the benchmark summary It measured a receiver configured with receive.unpackLimit=0, which keeps every received pack. With Git's defaults a host can discard duplicate objects, so the row overstated gcrypt's lasting storage cost. The upload size row still shows what each push transfers. --- docs/benchmarks.md | 9 ++++----- scripts/reproduce-benchmark.sh | 16 ---------------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index d2b4dfd..5c8b99a 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -41,7 +41,6 @@ lightly loaded. The runner's `summary.md`: | 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 5 tiny pushes | 891 MiB | 5,272 MiB | 878 MiB | | Total client disk after fresh fetch (.git + auxiliary cache/state) | 891 MiB | 1,780 MiB | 1,779 MiB | ### Second macOS machine (2026-10-08) @@ -61,7 +60,6 @@ run (load average about 7-16 on 10 cores). The ordering is identical: | Data sent per tiny push | 4.8 KB | 921 MB | 9.5 KB | | Peak memory, initial push | 1.27 GiB | 0.96 GiB | 1.27 GiB | | Peak memory, tiny push | 37 MiB | 887 MiB | 9 MiB | -| Remote size after 5 tiny pushes | 891 MiB | 5,272 MiB | 878 MiB | | Total client disk after fresh fetch (.git + auxiliary cache/state) | 891 MiB | 1,780 MiB | 1,779 MiB | The sections below record earlier runs and the investigation that led here. @@ -165,8 +163,10 @@ update followed by client object-store growth). RSS is MiB. | 4 | 0.09 s / 37.3 MiB / 4,763 / 3,359 B | 25.50 s / 887.1 MiB / 921,318,660 / 9,706 B | 1.04 s / 39.0 MiB / 9,522 / 4,501 B | | 5 | 0.08 s / 37.3 MiB / 4,759 / 3,356 B | 25.41 s / 887.1 MiB / 921,322,283 / 9,927 B | 1.15 s / 39.1 MiB / 9,577 / 4,498 B | -In the 2026-10-03 run, gcrypt added about 921 MiB to the remote on each tiny -push. E2EE's first tiny push took 5.20 s and used 522.3 MiB peak RSS. Later +In the 2026-10-03 run, gcrypt sent about 921 MiB on each tiny push. The +receiver kept every pack because of `receive.unpackLimit=0`; with Git's +defaults a host may discard the duplicate objects, so this is transfer, not +lasting storage growth. E2EE's first tiny push took 5.20 s and used 522.3 MiB peak RSS. Later pushes took 1.32 to 1.53 s, used about 38 MiB, and added 9.3 to 9.6 KiB. E2EE's median tiny push and fetch were below 1.5 s and 1.1 s, respectively. @@ -266,7 +266,6 @@ later revisions default to `/var/tmp` on Linux. | Data sent per tiny push | 4.5 KB | 921 MB | 8.9 KB | | Peak memory, initial push | 1.11 GiB | 0.96 GiB | 1.11 GiB | | Peak memory, tiny push | 44 MiB | 884 MiB | 48 MiB | -| Remote size after 5 tiny pushes | 891 MiB | 5,272 MiB | 878 MiB | | Total client disk after fresh fetch (.git + auxiliary cache/state) | 891 MiB | 1,780 MiB | 1,779 MiB | The ordering matches the macOS run except initial encryption, where gcrypt is diff --git a/scripts/reproduce-benchmark.sh b/scripts/reproduce-benchmark.sh index 95b7e6c..9b0b98b 100755 --- a/scripts/reproduce-benchmark.sh +++ b/scripts/reproduce-benchmark.sh @@ -510,11 +510,6 @@ format_sent_bytes() { if [[ -z $1 || $1 == - ]]; then printf '—'; else awk -v n="$1" 'BEGIN { if (n >= 1000000) printf "%.0f MB", n / 1000000; else printf "%.1f KB", n / 1000 }'; fi } -if [[ $e2ee_only == 1 ]]; then - common_pushes=$tiny_commits -else - common_pushes=$gcrypt_tiny_commits -fi summary_file="$results_dir/summary.md" { printf '# Benchmark summary\n\n' @@ -557,21 +552,10 @@ summary_file="$results_dir/summary.md" "$(format_mib "$(metric plain tiny_push all 5)")" \ "$(format_mib "$(metric gcrypt tiny_push all 5)")" \ "$(format_mib "$(metric e2ee tiny_push all 5)")" - tiny_push_label="tiny pushes" - if (( common_pushes == 1 )); then - tiny_push_label="tiny push" - fi - printf '| Remote size after %s %s | %s | %s | %s |\n' "$common_pushes" "$tiny_push_label" \ - "$(format_mib "$(metric plain tiny_push "$common_pushes" 7)")" \ - "$(format_mib "$(metric gcrypt tiny_push "$common_pushes" 7)")" \ - "$(format_mib "$(metric e2ee tiny_push "$common_pushes" 7)")" printf '| Total client disk after fresh fetch (.git + auxiliary cache/state) | %s | %s | %s |\n' \ "$(format_mib "$(total_client_disk_bytes plain)")" \ "$(format_mib "$(total_client_disk_bytes gcrypt)")" \ "$(format_mib "$(total_client_disk_bytes e2ee)")" - if (( common_pushes != tiny_commits )); then - printf '\nThe remote-size row uses %s pushes because gcrypt ran %s tiny pushes.\n' "$common_pushes" "$gcrypt_tiny_commits" - fi } >"$summary_file" run_complete=true