+ 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.
+
> [!WARNING]
-> This is an early prototype, not yet a safe backup system. The repository
-> format is unstable and there are no compatibility guarantees between
-> versions. Keep an independent copy of every repository and key.
-
-The implemented v4 protocol gives every published generation a fresh random
-root key. Each active reader receives one small public-key envelope for that
-generation; pack ciphertext is stored only once, independent of reader count,
-and is authenticated in bounded-memory 1 MiB segments.
-See the [design overview](DESIGN.md) and [normative specification](SPEC.md).
-
-## The problem this project solves
-
-Ordinary Git hosting must understand a repository to provide web diffs, pull
-request review, search, and hosted CI. Even a private repository therefore
-reveals its files, paths, branches, commit graph, authors, and messages to the
-host.
-
-`git-remote-e2ee` is for the different case where the storage provider should
-learn none of that, while trusted clients still need ordinary Git semantics. It
-keeps encryption, history validation, fast-forward checks, and conflict
-handling on clients. The server is reduced to two jobs:
-
-1. store immutable ciphertext objects;
-2. atomically replace one opaque head value if it has not changed.
-
-The project also addresses collaboration without a repository-wide private key
-or passphrase. Every device has its own key, administrators can add or revoke a
-device without rewriting historical packs, readers and writers can have
-different permissions, and concurrent writers cannot silently overwrite each
-other.
-
-This tradeoff is intentional: a host that cannot read the repository also
-cannot provide meaningful plaintext diffs, search, or review by itself.
-
-## Comparison with existing tools
-
-File-filter tools and encrypted remote helpers solve different problems.
-[`git-crypt`](https://github.com/AGWA/git-crypt) and
-[`transcrypt`](https://github.com/elasticdog/transcrypt) hide selected files in
-an otherwise normal repository. [`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt)
-and `git-remote-e2ee` hide the complete inner repository.
-
-Legend: **○** supported, **△** supported with caveats or extra setup, **×** not
-supported.
-
-| What you can do | `git-crypt` | `transcrypt` | `git-remote-gcrypt` | `git-remote-e2ee` |
-|---|:---:|:---:|:---:|:---:|
-| Hide the complete repository from the host | × | × | ○ | ○ |
-| Encrypt only selected files | ○ | ○ | × | × |
-| Use GitHub or GitLab as storage | ○ | ○ | △¹ | ○ |
-| Keep using normal `clone`, `pull`, and `push` | ○ | ○ | ○ | ○ |
-| Keep a hosted pull-request workflow | ○² | ○² | △² | △² |
-| See encrypted-content diffs on the host | × | × | × | × |
-| Run CI after explicitly providing a key | △ | △ | △ | △ |
-| Avoid distributing one shared passphrase or private key | ○³ | × | ○ | ○ |
-| Add a collaborator using only their public key | ○³ | × | ○ | ○ |
-| Revoke one collaborator independently | △⁴ | × | ○ | ○ |
-| Add a collaborator without rewriting bulk history | ○ | ○ | ○ | ○ |
-| Separate reader, writer, and administrator roles | × | × | × | ○ |
-| Reject concurrent-writer races without silent overwrite | ○ | ○ | ×⁵ | ○ |
-| Transfer only incremental data for small updates | △⁶ | △⁶ | △¹ | ○ |
-| Detect a storage rollback or fork after a prior sync | × | × | × | ○⁷ |
-| Branches | ○ | ○ | ○ | ○ |
-| Tags and remote branch deletion | ○ | ○ | ○ | ×⁸ |
-| Established, production-mature project | ○ | ○ | ○ | △ |
-
-1. gcrypt is incremental with its local and rsync backends. Its
- [performance documentation](https://manpages.debian.org/trixie/git-remote-gcrypt/git-remote-gcrypt.1.en.html#PERFORMANCE)
- warns that arbitrary Git and SFTP backends may upload the complete history
- on every push.
-2. File-filter repositories retain normal hosted review for visible data, but
- the host cannot show plaintext diffs for encrypted files. A whole-remote
- workflow needs an authorized client or CI job to decrypt and produce an
- inner diff; the carrier repository's own diff is not meaningful.
-3. This refers to git-crypt's GPG-user mode. It still uses an internal
- repository key, encrypted separately to each GPG recipient.
-4. Revoking a git-crypt user requires rotating the repository key and
- re-encrypting the protected files.
-5. gcrypt has a longstanding behavior where pushes are effectively force
- pushes; its explicit-force option prevents accidental use but does not add
- compare-and-swap writer coordination.
-6. A changed encrypted file becomes a new complete ciphertext blob, so Git
- cannot efficiently delta-compress it against the previous plaintext.
-7. A returning E2EE client pins observed history and rejects rollback or a
- different successor chain. A fresh client still needs an authenticated
- invitation checkpoint or an external anchor.
-8. The current E2EE implementation accepts destinations under
- `refs/heads/*`; tag pushes and branch deletion fail without publication.
-
-### Performance: practical full sync, incremental updates
-
-The local benchmark supports three practical conclusions:
-
-- Full encryption and reconstruction are in the same performance class as
- plain Git and git-remote-gcrypt for a large repository.
-- Incremental E2EE push and fetch are comparable to plain Git and were faster
- than git-remote-gcrypt in every measured incremental case.
-- Incremental backend payload and storage grow with the new Git pack plus small
- protocol metadata, not with the complete repository history.
-
-The tools were measured once against the same 867 MiB reachable Godot history
-using local-filesystem backends:
-
-| Operation | Plain Git† | `git-remote-gcrypt` | `git-remote-e2ee` |
-|---|---:|---:|---:|
-| Initial push | 30.51 s | 12.26 s | 13.78 s |
-| Fresh fetch | 30.26 s | 30.78 s | 31.75 s |
-| Tiny push | 0.17 s | 0.54 s | 0.08 s |
-| Add incompressible 10 MiB | 0.60 s | 0.66 s | 0.40 s |
-| Add 1,000 small files | 0.56 s | 0.69 s | 0.34 s |
-| Fetch tiny update | 0.12 s | 0.50 s | 0.09 s |
-| Fetch 10 MiB update | 0.58 s | 0.64 s | 0.23 s |
-| Fetch 1,000-file update | 0.08 s | 0.47 s | 0.10 s |
-
-† Plain Git and an opaque encrypted remote do different backend work. Plain
-Git indexes a server-side object database and may construct a new transfer pack
-during fetch. E2EE and gcrypt store encrypted pack files and replay them
-directly. That is efficient on dumb storage, but the host cannot provide
-plaintext diffs, search, shallow or partial-clone negotiation, or other
-server-side Git features.
-
-The corresponding remote growth was incremental:
-
-| Update | Plain Git | `git-remote-gcrypt` | `git-remote-e2ee` |
-|---|---:|---:|---:|
-| Tiny commit | 1.5 KiB | 1.7 KiB | 3.5 KiB |
-| Add incompressible 10 MiB | 10.0 MiB | 10.0 MiB | 10.0 MiB |
-| Add 1,000 small files | 110.2 KiB | 67.2 KiB | 79.6 KiB |
-| Existing 877 MiB encrypted history rewritten | — | 0 bytes (this run) | 0 bytes |
-
-Adding a second E2EE reader wrote 3,379 bytes of policy and envelope metadata
-and rewrote zero existing pack bytes. With 100 active readers, content-pack
-storage and client repository size remained unchanged; only small per-reader
-metadata and public-key wrapping work grew.
-
-These are exploratory single runs, not stable release claims or
-network-hosting benchmarks. The gcrypt result uses its efficient local backend
-and did not cross its periodic repack threshold; its arbitrary Git transport
-has a different performance profile. A fresh clone necessarily transfers and
-checks the complete history.
-
-See [BENCHMARKS.md](BENCHMARKS.md#comparison-with-the-tools-in-the-feature-table)
-for method, memory, disk growth, tool revisions, and limitations.
-
-## When another tool is a better fit
-
-- Use **git-crypt** when only a few files are secret and retaining normal
- GitHub or GitLab diffs, reviews, search, and integrations for the rest of the
- repository matters more than hiding the repository as a whole.
-- Use **transcrypt** when selected-file encryption with a shared passphrase is
- acceptable and its simple shell-based setup is preferable.
-- Use **git-remote-gcrypt** when you need an established whole-remote tool,
- already use GPG, and can use its efficient local or rsync backend while
- accepting its force-push and periodic-repack behavior.
-- Use an ordinary **private Git repository** when you trust the host with the
- plaintext and need first-class hosted pull requests, code search, previews,
- or CI without managing decryption keys.
-- Do **not** use git-remote-e2ee as the only backup yet. Choose a mature tool if
- you require stable repository formats, recovery tooling, tags, branch
- deletion, shallow clones, or a production support commitment today.
-
-Choose `git-remote-e2ee` when the complete repository and its metadata must be
-opaque to storage, collaborators should use independent device keys, membership
-changes must not rewrite bulk history, and incremental synchronization plus
-client-side rollback and writer-race protection are worth giving up server-side
-plaintext features.
-
-## Current features
-
-- Normal Git remote-helper workflow for clone, fetch, pull, and push
-- Per-device HPKE (X25519/HKDF-SHA-256/ChaCha20-Poly1305) generation-key envelopes
-- A fresh random generation root for every successful HEAD publication
-- Domain-separated HKDF subkeys and XChaCha20-Poly1305 payload encryption
-- Streaming authenticated pack encryption and decryption with bounded Rust-side
- memory rather than whole-pack buffers
-- Incremental Git connectivity verification from a locally pinned, previously
- verified ref frontier
-- Ed25519-signed, append-only policy and manifest chains
-- 1-of-N recipient access: each authorized device unlocks with only its own key
-- Separate reader, writer, and administrator authorization
-- Atomic device addition and revocation without rewriting historical packs
-- An authenticated backward key chain: the current key unlocks earlier
- generations, while an earlier key cannot unlock later generations
-- Delta manifests and incremental Git packs rather than full repository snapshots
-- Client-side fast-forward enforcement and explicit force push
-- Atomic stale-writer rejection through compare-and-swap
-- Per-client rollback and manifest-fork detection after first observation
-- Complete signed history, ciphertext, and reconstructed Git object-graph
- verification before refs or continuity pins move
-- Filesystem storage backend
-- Carrier-Git backend for GitHub, GitLab, a bare repository, or another ordinary
- Git remote
-
-Push destinations are currently limited to branches under `refs/heads/*`. Tags
-and branch deletion are rejected without publishing a new manifest.
-
-## Install
-
-The project currently builds two binaries:
-
-- `git-e2ee`: repository initialization, key generation, and diagnostic CLI
-- `git-remote-e2ee`: helper invoked automatically by Git for `e2ee::` URLs
+> 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.
+
+## Why end-to-end encryption?
+
+**"Private" on a Git host means private from other people, not from the host.**
+A private GitHub or GitLab repository is stored in a form the provider can read:
+every file, every commit message, every branch name, and who wrote what. That is
+what lets the host show you diffs and run CI, but it also means your code and
+notes are readable by anyone who gets access to the host's side: a breach, a
+leaked access token or third-party app, an insider, a legal demand, or a policy
+change you did not choose.
+
+**End-to-end encryption (E2EE) moves the keys to your devices.** Your repository
+is encrypted on your computer before it is uploaded, and only devices you have
+authorized can decrypt it. The host stores data it cannot read. If the host is
+breached or compelled to hand over your repository, there is nothing readable
+to hand over. If someone tampers with the stored data, your clients notice and
+refuse it.
+
+With `git-remote-e2ee` this happens underneath Git. You add a remote with an
+`e2ee::` URL and keep working exactly as before.
+
+**Good fits:** personal notes and journals, research before publication, client
+or NDA work that must live on third-party storage, private configuration, and
+backups on storage you do not control.
+
+**What you give up:** because the host cannot read the repository, it cannot
+show it to you either. There are no web diffs, pull-request reviews, code
+search, or hosted CI on the plaintext. You are also responsible for your keys:
+if every authorized key is lost, the data cannot be recovered by anyone.
+
+## How it works
+
+1. **Every device has its own key.** There is no shared passphrase. An
+ administrator authorizes a device using only its public key and can revoke it
+ later.
+2. **Git runs on your machine; the helper encrypts what leaves it.** Git hands
+ the helper ordinary packs and refs. The helper encrypts them and uploads
+ opaque objects, then atomically moves one opaque `HEAD` pointer.
+3. **The storage only stores.** It keeps immutable ciphertext and performs one
+ compare-and-swap on `HEAD`. Fast-forward checks, conflict handling, and
+ history verification all happen on clients, so a concurrent push can never
+ silently overwrite someone else's work.
+
+Storage backends today: a **local or mounted directory**, and **any ordinary
+Git remote** (GitHub, GitLab, a bare repository) used as a ciphertext carrier.
+
+## Quick start
+
+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`.
```console
-cargo install --path .
-git-e2ee --help
+cargo install --git https://github.com/combinatrix-ai/git-remote-e2ee
```
-Both binaries must be on `PATH` for native Git integration.
-
-## Quick start: filesystem backend
-
-Create a repository key and encrypted storage directory:
+Use an empty GitHub repository as encrypted storage and push an existing
+project to it:
```console
-git-e2ee keygen --output /safe/place/repository.key.json
-git-e2ee init \
- --storage /srv/encrypted/example \
- --key /safe/place/repository.key.json
-```
-
-Add it to an existing local Git repository:
+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
-```console
-git remote add private 'e2ee::/srv/encrypted/example'
-git config remote.private.e2ee-key /safe/place/repository.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 main
-git fetch private
```
-The key path is local Git configuration. The key file is never written to the
-encrypted remote and must never be committed.
+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.
-## Add and revoke devices
+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.
-Each device has a different private key. Only the small public device file is
-given to an administrator. If machine A created the repository, set up machine
-B like this (substitute the repository root printed by A's `keygen`):
+## How it compares
-```console
-# Machine B
-git-e2ee keygen \
- --repository-root \
- --output /safe/place/machine-b.key.json
-git-e2ee device-export \
- --key /safe/place/machine-b.key.json \
- --output machine-b.public.json
-
-# Machine A, after receiving only machine-b.public.json
-git-e2ee device-add \
- --storage /srv/encrypted/example \
- --key /safe/place/repository.key.json \
- --device machine-b.public.json
-```
+There are two families of encrypted-Git tools, and they answer different
+questions:
-The default added device can read and write but cannot change policy. Pass
-`--admin` to grant administration too. `git-e2ee device-list` prints opaque
-device IDs; revoke one with:
+- **File filters** ([`git-crypt`](https://github.com/AGWA/git-crypt),
+ [`transcrypt`](https://github.com/elasticdog/transcrypt)) encrypt the contents
+ of selected files inside an otherwise normal repository. The host still sees
+ the repository's structure and history, and keeps working normally for
+ everything left unencrypted.
+- **Encrypted remotes**
+ ([`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt),
+ `git-remote-e2ee`) encrypt the whole repository, history included. The host
+ sees opaque blobs and nothing else of the inner repository.
-```console
-git-e2ee device-revoke \
- --storage /srv/encrypted/example \
- --key /safe/place/repository.key.json \
- --device-id
-```
+### What the storage host can learn
-For a carrier-Git backend, use `--remote ` instead of `--storage`
-with `device-add`, `device-list`, and `device-revoke`.
+| | Private repo | `git-crypt` | `transcrypt` | `git-remote-gcrypt` | `git-remote-e2ee` |
+|---|:---:|:---:|:---:|:---:|:---:|
+| Contents of files you chose to protect | visible | hidden | hidden | hidden | hidden |
+| Contents of all other files | visible | visible | visible | hidden | hidden |
+| File names and directory layout | visible | visible | visible | hidden | hidden |
+| Commit messages, authors, and dates | visible | visible | visible | hidden | hidden |
+| Branch names and the commit graph | visible | visible | visible | hidden | hidden |
+| Which files changed, their sizes, identical files | visible | visible¹ | visible | hidden | hidden |
+| When you push, and roughly how much | visible | visible | visible | visible | visible |
+| Collaborator keys | account list | key fingerprints² | — (shared password) | count only³ | count, public keys, roles⁴ |
-Adding or revoking a device publishes a fresh generation key in one
-compare-and-swap operation. Its header contains one small envelope for each
-active reader, while all historical manifests and packs stay unchanged. A newly
-added reader can use the current key's authenticated backward links to decrypt
-the complete history. A revoked reader retains the snapshot it could already
-decrypt but receives no key for the new generation or later ones.
-The CLI keeps an administrative continuity pin next to the administrator key as
-`.admin-state.json`. Preserve that file together with the key.
+### What each tool can do
-This is deliberately **not multisig**. The current implementation accepts one
-authorized administrator signature for a policy change. The wire format has an
-administrator threshold and signature array so a future version can add M-of-N,
-but this version fails closed on any threshold other than 1.
+| | `git-crypt` | `transcrypt` | `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 | ○ | ○ | × | × |
+| Tampering detected | △⁶ | ×⁷ | ○ | ○ |
+| No shared secret; add people by public key | ○² | × | ○ | ○ |
+| Revoke one collaborator | ×⁸ | × | △⁹ | ○¹⁰ |
+| Separate reader, writer, and administrator roles | × | × | × | △¹¹ |
+| Concurrent pushes can't silently overwrite each other | ○ | ○ | ×¹² | ○ |
+| Small updates upload small amounts of data | △¹³ | △¹³ | △⁵ | ○ |
+| Detects a rolled-back or forked remote | × | × | × | △¹⁴ |
+| Tags and remote branch deletion | ○ | ○ | ○ | ×¹⁵ |
+| Mature, stable format | ○ | ○ | ○ | × |
+| Cryptography | AES-256-CTR, HMAC-SHA1 SIV | AES-256-CBC via OpenSSL | OpenPGP (GnuPG) | XChaCha20-Poly1305, HPKE (X25519), Ed25519 |
+
+○ supported · △ with caveats · × not supported
+
+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. Each device must find its own envelope before it can decrypt anything, so
+ the policy is plaintext-structured: the host sees the number of devices,
+ their per-repository public keys, their roles, and policy changes. Do not
+ reuse device keys between repositories.
+5. 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.
+6. git-crypt authenticates encrypted file contents; file names, history, and
+ which files are encrypted are ordinary Git data.
+7. transcrypt uses unauthenticated CBC; its README lists ciphertext
+ malleability as a known limitation.
+8. git-crypt's README states that it does not support revoking access.
+ Rotating the key means re-encrypting the protected files by hand.
+9. Participants can be removed from `gcrypt.participants`, but gcrypt does not
+ document a revocation workflow.
+10. Revocation publishes a fresh key without rewriting history. Like every tool
+ here, it cannot take back data the device already had.
+11. The protocol separates the three roles, but the CLI currently grants either
+ read and write, or read, write, and administration.
+12. Every gcrypt push is effectively a force push. Its explicit-force option
+ prevents accidents but does not coordinate concurrent writers.
+13. A changed encrypted file becomes a new, unrelated ciphertext blob, so Git
+ cannot delta-compress it against the previous version.
+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. Pushes are limited to `refs/heads/*` for now. Tag pushes and branch
+ deletion fail without publishing anything.
+
+### Other approaches
+
+- **Keybase encrypted Git** is end-to-end encrypted, but it only works with
+ Keybase's own service.
+- **An encrypted filesystem under a bare repository** (rclone crypt,
+ Cryptomator, gocryptfs) hides contents, but not the repository's file
+ structure, sizes, or update pattern. Two machines pushing at once through a
+ sync service can also corrupt the repository.
+- **git-annex encrypted special remotes** protect large annexed files, not the
+ Git history itself.
+
+### When another tool is a better fit
+
+- **git-crypt**: only a few files are secret, and you want GitHub or GitLab to
+ keep working normally for the rest.
+- **transcrypt**: the same, and a shared password with a simple shell setup is
+ acceptable.
+- **git-remote-gcrypt**: you want an established whole-repository tool, already
+ use GPG, and can use its local or rsync backend.
+- **An ordinary private repository**: you trust the host and want pull
+ requests, search, previews, and CI.
+- **Anything mature**: you need a stable format, recovery tooling, tags, or
+ shallow clones today.
+
+Choose `git-remote-e2ee` when the whole repository and its history must be
+opaque to storage, each collaborator should hold their own key, and you want
+Git's normal conflict behavior on storage you do not trust.
-## Carrier-Git backend
+## Security model
-An ordinary Git repository can act as the ciphertext carrier. It may be empty
-or contain unrelated branches; `git-remote-e2ee` uses its own
-`git-remote-e2ee` branch.
+The storage provider is treated as malicious. `git-remote-e2ee` aims to
+guarantee:
-```console
-git-e2ee keygen --output /safe/place/carrier.key.json
-git-e2ee carrier-init \
- --remote https://git.example/user/encrypted-carrier.git \
- --key /safe/place/carrier.key.json
+- confidentiality of inner contents and Git metadata: files, paths, refs,
+ object IDs, authors, and messages;
+- authenticity and integrity of every fetched state;
+- continuity for a clone that has synced before, so rollback and forks are
+ rejected;
+- that revoked devices get no keys for anything published after revocation.
-git remote add private \
- 'e2ee::git+https://git.example/user/encrypted-carrier.git'
-git config remote.private.e2ee-key /safe/place/carrier.key.json
-git push -u private main
-```
+It does **not** prevent:
-Clone by supplying the key path once:
+- the host deleting data or refusing service;
+- a brand-new clone being shown an old or forked history, or different clients
+ being shown different histories (that needs an external anchor, which is on
+ the roadmap);
+- an authorized writer making destructive changes;
+- a revoked device reading what it could read before revocation;
+- traffic analysis: update times, object sizes, and total growth are visible;
+- future quantum attacks: key exchange uses X25519.
-```console
-git -c e2ee.key=/safe/place/carrier.key.json clone \
- 'e2ee::git+https://git.example/user/encrypted-carrier.git'
-```
+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.
-The helper persists the path as local `remote.origin.e2ee-key` configuration in
-the new clone. Later `git fetch`, `git pull`, and `git push` need no wrapper.
+## Performance
-The carrier stores only the outer structure below:
+Full transfers take about as long as plain Git, and small updates stay fast.
+On a local-filesystem backend with the 867 MiB Godot history, measured in a
+single run:
-```text
-refs/heads/git-remote-e2ee
-└── e2ee/
- ├── HEAD
- ├── objects/aa//00000000
- ├── manifests/bb//00000000
- └── policies/cc//00000000
-```
+| Operation | Plain Git | `git-remote-gcrypt` | `git-remote-e2ee` |
+|---|---:|---:|---:|
+| Initial push | 30.51 s | 12.26 s | 13.78 s |
+| Fresh fetch | 30.26 s | 30.78 s | 31.75 s |
+| Tiny push | 0.17 s | 0.54 s | 0.08 s |
+| Fetch tiny update | 0.12 s | 0.50 s | 0.09 s |
-Ciphertext is divided into 32 MiB chunks so it can be carried as ordinary Git
-blobs. The host can still observe outer commit times, chunk counts and sizes,
-update frequency, and total growth.
+Plain Git is not directly comparable. A Git server indexes objects and builds
+packs per fetch, while encrypted remotes store and replay opaque packs. That is
+cheap on dumb storage, but it rules out server-side features such as partial
+clone. Each update stores only its new pack plus a few KiB of metadata. Adding a
+reader rewrote zero existing pack bytes. See [BENCHMARKS.md](BENCHMARKS.md) for
+the method, the remaining cases, and the limitations.
-## Security model
+## FAQ
-The storage provider is treated as malicious for confidentiality and integrity.
-Authenticated encryption, ciphertext-addressed objects, signatures, and the
-manifest hash chain detect modification. A returning client pins the newest
-manifest it has observed and rejects a lower generation or a history that no
-longer descends from that pin.
-
-The current security claim is:
-
-- confidentiality of inner repository contents and Git metadata;
-- authenticity and integrity of fetched repository state;
-- continuity from the state previously observed by the same local clone.
-- future-generation exclusion after an atomic device revocation.
-
-It does **not** independently prevent:
-
-- rollback presented to a fresh client;
-- equivocation between clients shown different valid histories;
-- freezing a client at its last valid state;
-- deletion or denial of service by storage;
-- destructive changes made by an authorized writer;
-- disclosure of data a revoked device could access before its revocation;
-- disclosure of the complete historical snapshot at or before any leaked
- generation key;
-- a revoked writer and colluding storage presenting a pre-revocation fork to a
- fresh or stale client;
-- policy rollback, freezing, or equivocation presented to a fresh client.
-
-Global rollback and equivocation resistance require gossip or an external
-transparency anchor. A returning clone pins both manifest and policy generation.
-The administrative CLI also pins its last published state and does not
-automatically retry a lost CAS race; inspect the winner and rerun the operation.
-
-Generation headers and policy objects must remain plaintext-structured so a
-device can find its envelope without already knowing the generation key.
-Consequently the storage host can see device count, per-repository public keys,
-roles, policy changes, and generation numbers. Device keys should never be
-reused between repositories. Inner refs, object IDs, paths, authors, messages,
-generation keys, derived subkeys, and contents remain encrypted.
-
-A generation key is intentionally a transferable snapshot capability: leaking
-`K_t` exposes generations `0..=t` through the backward links. It does not expose
-generation `t+1` or later. Continued future disclosure therefore requires a
-reader's private device key, repeated release of each later generation key or
-plaintext, or continued access to an authorized device. This is key regression,
-not forward secrecy for already published history.
-
-If two authorized writers race, each may locally create a valid generation key
-and ciphertext, but compare-and-swap permits only one HEAD update. The loser
-must discard its unpublished generation and retry from the winning HEAD. Anyone
-who received the loser's key can decrypt that losing unpublished snapshot; CAS
-prevents it from becoming repository history but cannot retract already shared
-plaintext or keys.
-
-## Storage protocol
-
-The backend-neutral interface has four logical operations. Immutable object
-writes are staged so the final ciphertext ID can be learned while streaming:
-
-```text
-begin_object(kind) -> writable stage; stage.finish(id)
-open_object(kind, id) -> reader
-read_head()
-compare_and_swap_head(expected, next)
-```
+**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.
-The filesystem backend implements head CAS with an advisory lock and atomic
-rename. Stages are created inside the backend's own filesystem or checkout, so
-publication does not depend on cross-filesystem rename. The carrier-Git backend
-implements head CAS as a normal fast-forward push to the outer branch. A future
-S3 backend can use multipart upload plus conditional writes, but each provider
-must be capability-tested; “S3 compatible” does not by itself promise correct
-compare-and-swap behavior.
+**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.
-A killed filesystem writer can leave an unreachable file under `.staging/`.
-No published object or `HEAD` points to it, and stale `.stage-*` entries may be
-deleted when no writer is running. Automatic age-based cleanup belongs to
-future garbage collection.
+**Does revoking a device hide old history from it?**
+No. It keeps whatever it could already decrypt. It cannot read anything
+published after the revocation.
-See [DESIGN.md](DESIGN.md) for the protocol and threat-model details.
+**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 device key.
-## Tests
+**Can the host roll my repository back?**
+It can serve old data. A clone that has synced before detects this and refuses
+it. A fresh clone cannot tell yet.
-```console
-cargo test --all-targets
-cargo clippy --all-targets -- -D warnings
-cargo fmt --all -- --check
-```
+**Is this post-quantum?**
+No. An attacker who records ciphertext today and later breaks X25519 could read
+it.
-The test suite includes:
-
-- incremental push and reconstruction into a fresh repository;
-- native clone, fetch, pull, push, dry-run, refspec, and force-push behavior;
-- rollback, same-generation fork, and ciphertext-tampering rejection;
-- streaming-AEAD chunk boundaries, wrong keys/AAD, reordering, duplication,
- truncation, trailing bytes, forged sizes, and legacy-format rejection;
-- independent device add/read/write, non-admin rejection, genesis-substitution
- rejection, administrative rollback pinning, device revocation without pack
- rewrites, and multi-generation offline catch-up;
-- malformed predecessor-link, generation-key commitment, recipient-set, and
- signed-but-incomplete Git object-graph rejection;
-- two independent carrier writers racing real `git push` processes, with
- exactly one winner;
-- plaintext-absence checks across carrier history;
-- applicable black-box scenarios independently reimplemented from Git
- upstream's
- [`t/t5801-remote-helpers.sh`](https://github.com/git/git/blob/master/t/t5801-remote-helpers.sh).
-
-A local bare Git repository is sufficient for deterministic carrier concurrency
-tests: local-path pushes still execute Git's real `receive-pack`, ref locking,
-and fast-forward checks. Hosted smoke tests remain useful for authentication,
-request limits, and provider-specific policy.
-
-## Roadmap
-
-- M-of-N administrative authorization (future format version; threshold 1 only today)
-- Recovery and device-key replacement workflows
-- Automatic stale-push fetch/retry workflow
-- Persistent partial-clone cache for large carrier repositories
-- S3 conditional-write backend and provider compatibility suite
-- Safe compaction and garbage collection
-- Signed checkpoint transitions for compaction without ambiguous key-chain semantics
-- Optional gossip or transparency-log anchoring
-- Shallow and partial clone support
-
-## Performance benchmarks
-
-An opt-in local harness measures initial encryption, fresh reconstruction,
-verification, and incremental updates without contacting the source
-repository's configured remote. See [BENCHMARKS.md](BENCHMARKS.md). Benchmark
-outputs, repository keys, and reconstructed data must not be committed.
+## Status and roadmap
-## License
+Working today: clone, fetch, pull, and push; the directory and carrier-Git
+backends; per-device keys; adding and revoking devices; incremental transfer;
+and rollback and race detection.
-Licensed under either of
+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, tags and branch deletion, shallow and partial clone,
+and optional transparency-log anchoring.
-- [Apache License, Version 2.0](LICENSE-APACHE), or
-- [MIT License](LICENSE-MIT)
+## Documentation
+
+- [User guide](docs/guide.md): backends, cloning, device management, and the
+ storage layout
+- [DESIGN.md](DESIGN.md): design overview and threat model
+- [SPEC.md](SPEC.md): normative protocol specification
+- [BENCHMARKS.md](BENCHMARKS.md): benchmark method and results
+- [CONTRIBUTING.md](CONTRIBUTING.md): building, testing, and what the test
+ suite covers
+
+## License
-at your option.
+Licensed under either of the [Apache License, Version 2.0](LICENSE-APACHE) or
+the [MIT License](LICENSE-MIT), at your option.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..9bcb4bc
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,21 @@
+# Security
+
+Please report vulnerabilities privately through GitHub's
+[private vulnerability reporting](https://github.com/combinatrix-ai/git-remote-e2ee/security/advisories/new)
+rather than opening a public issue.
+
+`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 [DESIGN.md](DESIGN.md) and
+[SPEC.md](SPEC.md). Reports are especially welcome about:
+
+- plaintext, refs, paths, or other inner metadata reaching storage;
+- accepting tampered, rolled-back, or forked state that a returning clone
+ should reject;
+- a revoked device obtaining keys for later generations;
+- authorization bypass between reader, writer, and administrator roles;
+- key material leaking into logs, Git configuration, or the remote.
+
+Limits already documented, such as rollback shown to a fresh clone or metadata
+visible to storage, are not vulnerabilities by themselves. Proposals to close
+them are still welcome as issues.
diff --git a/docs/art/overview.svg b/docs/art/overview.svg
new file mode 100644
index 0000000..1a4e913
--- /dev/null
+++ b/docs/art/overview.svg
@@ -0,0 +1,47 @@
+
diff --git a/docs/guide.md b/docs/guide.md
new file mode 100644
index 0000000..b0624e5
--- /dev/null
+++ b/docs/guide.md
@@ -0,0 +1,184 @@
+# User guide
+
+This guide covers everyday use. For why the tool exists and how it compares to
+alternatives, see the [README](../README.md). For the protocol, see
+[DESIGN.md](../DESIGN.md) and [SPEC.md](../SPEC.md).
+
+## Install
+
+The project builds two binaries:
+
+- `git-e2ee`: key generation, repository setup, device management, and
+ diagnostics
+- `git-remote-e2ee`: the remote helper Git runs automatically for `e2ee::` URLs
+
+```console
+cargo install --git https://github.com/combinatrix-ai/git-remote-e2ee
+git-e2ee --help
+```
+
+From a checkout, use `cargo install --path .` instead. Both binaries must be on
+`PATH` for Git to find the helper.
+
+## Keys
+
+`git-e2ee keygen` writes a private device key file. The first key created for a
+repository is its administrator. The key path is stored only in local Git
+configuration. The key file is never written to the encrypted remote and must
+never be committed.
+
+The CLI keeps an administrative continuity pin next to an administrator key,
+named `.admin-state.json`. Back up that file together with the key.
+
+## Git host as storage (carrier-Git backend)
+
+Any ordinary Git repository can carry the ciphertext: GitHub, GitLab, or a bare
+repository. It may be empty or contain unrelated branches. `git-remote-e2ee`
+uses its own `git-remote-e2ee` branch.
+
+```console
+git-e2ee keygen --output /safe/place/carrier.key.json
+git-e2ee carrier-init \
+ --remote https://git.example/user/encrypted-carrier.git \
+ --key /safe/place/carrier.key.json
+
+git remote add private \
+ 'e2ee::git+https://git.example/user/encrypted-carrier.git'
+git config remote.private.e2ee-key /safe/place/carrier.key.json
+git push -u private main
+```
+
+The carrier holds only this outer structure:
+
+```text
+refs/heads/git-remote-e2ee
+└── e2ee/
+ ├── HEAD
+ ├── objects/aa//00000000
+ ├── manifests/bb//00000000
+ └── policies/cc//00000000
+```
+
+Ciphertext is split into 32 MiB chunks so it can be stored as ordinary Git
+blobs. The host can still observe outer commit times, chunk counts and sizes,
+update frequency, and total growth.
+
+## Directory as storage (filesystem backend)
+
+A local or mounted directory can hold the encrypted repository:
+
+```console
+git-e2ee keygen --output /safe/place/repository.key.json
+git-e2ee init \
+ --storage /srv/encrypted/example \
+ --key /safe/place/repository.key.json
+
+git remote add private 'e2ee::/srv/encrypted/example'
+git config remote.private.e2ee-key /safe/place/repository.key.json
+git push -u private main
+```
+
+Do not point two machines at the same directory through a file-sync service.
+Concurrent writers rely on the backend's atomic compare-and-swap, which a sync
+service does not provide across machines. Use the carrier-Git backend instead.
+
+## Clone
+
+Supply the key path once:
+
+```console
+git -c e2ee.key=/safe/place/carrier.key.json clone \
+ 'e2ee::git+https://git.example/user/encrypted-carrier.git'
+```
+
+The helper saves the path as `remote.origin.e2ee-key` in the new clone. Later
+`git fetch`, `git pull`, and `git push` need nothing extra.
+
+## Add a second device
+
+Each device has its own private key, and only the small public device file ever
+leaves it. If machine A created the repository, set up machine B like this.
+Substitute the repository root printed by A's `keygen`.
+
+```console
+# Machine B
+git-e2ee keygen \
+ --repository-root \
+ --output /safe/place/machine-b.key.json
+git-e2ee device-export \
+ --key /safe/place/machine-b.key.json \
+ --output machine-b.public.json
+
+# Machine A, after receiving only machine-b.public.json
+git-e2ee device-add \
+ --remote https://git.example/user/encrypted-carrier.git \
+ --key /safe/place/carrier.key.json \
+ --device machine-b.public.json
+```
+
+Machine B can then clone with its own key, as shown above.
+
+By default an added device can read and write but cannot change membership.
+Pass `--admin` to grant administration too. For the directory backend, use
+`--storage ` instead of `--remote ` with `device-add`,
+`device-list`, and `device-revoke`.
+
+## Revoke a device
+
+`git-e2ee device-list` prints opaque device IDs. Revoke one with:
+
+```console
+git-e2ee device-revoke \
+ --remote https://git.example/user/encrypted-carrier.git \
+ --key /safe/place/carrier.key.json \
+ --device-id
+```
+
+Adding or revoking a device publishes a fresh generation key in a single
+compare-and-swap. The new generation carries one small envelope per active
+reader, and all historical manifests and packs stay unchanged. A newly added
+device can use the current key's authenticated backward links to decrypt the
+complete history. A revoked device keeps what it could already decrypt, but it
+receives no key for the new generation or any later one.
+
+Membership changes need one administrator signature. This is deliberately
+**not multisig** yet: the wire format has an administrator threshold and a
+signature array so a future version can add M-of-N approval, but this version
+refuses any threshold other than 1.
+
+The administrative CLI does not retry automatically when it loses a
+compare-and-swap race. Inspect the winning state and run the command again.
+
+## Limits today
+
+- Pushes are limited to branches under `refs/heads/*`. Tag pushes and branch
+ deletion fail without publishing anything.
+- A push that loses a race with another writer fails. Fetch, integrate, and
+ push again.
+- Shallow and partial clones are not supported. A fresh clone downloads and
+ verifies the complete history.
+
+## Storage contract
+
+Backends implement four logical operations. Immutable object writes are staged
+so the final ciphertext ID can be learned while streaming:
+
+```text
+begin_object(kind) -> writable stage; stage.finish(id)
+open_object(kind, id) -> reader
+read_head()
+compare_and_swap_head(expected, next)
+```
+
+The filesystem backend implements head compare-and-swap with an advisory lock
+and atomic rename. Stages are created inside the backend's own filesystem or
+checkout, so publication does not depend on cross-filesystem rename. The
+carrier-Git backend implements it as a normal fast-forward push to the outer
+branch. A future S3 backend can use multipart upload plus conditional writes,
+but each provider must be capability-tested: "S3 compatible" does not by itself
+promise correct compare-and-swap behavior.
+
+A killed filesystem writer can leave an unreachable file under `.staging/`.
+Nothing published points to it, and stale `.stage-*` entries may be deleted
+when no writer is running. Automatic cleanup belongs to future garbage
+collection.
From 5f3c5139ff4ccf1a4929059ff7e94a6fe5a71644 Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 18:34:19 +0900
Subject: [PATCH 09/98] Add verified_refs to the durability test fixture after
merging main
---
src/repository.rs | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/repository.rs b/src/repository.rs
index f4a3e34..df7afdc 100644
--- a/src/repository.rs
+++ b/src/repository.rs
@@ -878,6 +878,7 @@ pub(crate) fn testing_write_client_state(
imported_generation: Some(generation),
policy_generation: Some(1),
repository_root: Some("durability-fixture".to_owned()),
+ verified_refs: BTreeMap::new(),
};
persist_client_state(path, &state)
}
From 2e897a52d37db3526e81b37ce9d15dd3ff35c94d Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 18:34:43 +0900
Subject: [PATCH 10/98] Lead with fast, safe, and no-master-key differences
---
README.ja.md | 12 +++++++-----
README.md | 42 +++++++++++++++++++++++++++---------------
2 files changed, 34 insertions(+), 20 deletions(-)
diff --git a/README.ja.md b/README.ja.md
index 5d7c061..d91e8df 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -41,13 +41,15 @@ GitHub や GitLab のプライベートリポジトリは、事業者が読め
**引き換えになるもの:** 保存先が中身を読めない以上、保存先で中身を見せることもできません。Web での差分表示、プルリクエストのレビュー、コード検索、ホスト側の CI は平文に対しては使えません。また、鍵は自分で管理します。許可した鍵をすべて失うと、誰にも復元できません。
-## 仕組み
+## git-remote-e2ee の特長
-1. **端末ごとに別の鍵を持ちます。** 共有パスフレーズはありません。管理者は端末の公開鍵だけで利用を許可でき、あとから取り消せます。
-2. **Git は手元で動き、外へ出るものだけを暗号化します。** Git から普通の pack と ref を受け取り、暗号化して中身の見えないオブジェクトとしてアップロードし、最後に中身の見えない `HEAD` ポインタを1つだけアトミックに更新します。
-3. **保存先は保存するだけです。** 変更されないオブジェクトを保管し、`HEAD` の compare-and-swap を1つ行うだけです。fast-forward の確認、競合の処理、履歴の検証はすべてクライアント側で行うので、同時に push しても他人の変更が黙って上書きされることはありません。
+Git リポジトリ全体の暗号化自体は新しいものではなく、[`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt) が長年実現してきました。`git-remote-e2ee` はその目的を引き継ぎ、暗号化リモートを日常的に使ううえで困る点を3つ解決します。
-現在使える保存先は、**ローカルまたはマウントしたディレクトリ**と、暗号文の運び役として使う**通常の Git リモート**(GitHub、GitLab、bare リポジトリ)です。
+- **速い: どのバックエンドでも差分だけを転送します。** push でアップロードするのは新しい Git の pack と数 KiB のメタデータだけで、GitHub や GitLab を保存先にしても同じです。gcrypt は Git や SFTP のバックエンドでは push のたびに履歴全体を上げ直し、予告なく repack することもあります。
+- **安全: 黙って force push されません。** fast-forward の確認はクライアントで行い、保存先の `HEAD` は compare-and-swap でしか動きません。2人が同時に push すると片方が勝ち、もう片方は普通に拒否されます。gcrypt では push が実質すべて force push なので、後の push が先の push を消してしまうことがあります。
+- **マスターキーなし: 端末ごとに別の鍵を持ちます。** 配って回るリポジトリのパスワードや共有の秘密鍵はありません。管理者は公開鍵で端末を追加でき、履歴を書き換えずに1台ずつ取り消せます。transcrypt は1つのパスワードを共有し、git-crypt は取り消しのできない1つのリポジトリ鍵を共有します。
+
+内部では、Git はいつもどおり手元で動きます。ヘルパーが Git から受け取った pack と ref を暗号化し、中身の見えない変更不可のオブジェクトとしてアップロードしてから、中身の見えない `HEAD` ポインタを1つだけアトミックに更新します。現在使える保存先は、**ローカルまたはマウントしたディレクトリ**と、暗号文の運び役として使う**通常の Git リモート**(GitHub、GitLab、bare リポジトリ)です。他のツールのほうが向いている場合も含めた詳しい比較は[他のツールとの比較](#他のツールとの比較)にあります。
## クイックスタート
diff --git a/README.md b/README.md
index 496fbd8..d9d72ed 100644
--- a/README.md
+++ b/README.md
@@ -58,21 +58,33 @@ show it to you either. There are no web diffs, pull-request reviews, code
search, or hosted CI on the plaintext. You are also responsible for your keys:
if every authorized key is lost, the data cannot be recovered by anyone.
-## How it works
-
-1. **Every device has its own key.** There is no shared passphrase. An
- administrator authorizes a device using only its public key and can revoke it
- later.
-2. **Git runs on your machine; the helper encrypts what leaves it.** Git hands
- the helper ordinary packs and refs. The helper encrypts them and uploads
- opaque objects, then atomically moves one opaque `HEAD` pointer.
-3. **The storage only stores.** It keeps immutable ciphertext and performs one
- compare-and-swap on `HEAD`. Fast-forward checks, conflict handling, and
- history verification all happen on clients, so a concurrent push can never
- silently overwrite someone else's work.
-
-Storage backends today: a **local or mounted directory**, and **any ordinary
-Git remote** (GitHub, GitLab, a bare repository) used as a ciphertext carrier.
+## Why git-remote-e2ee
+
+Encrypting a whole Git repository is not new;
+[`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt) has done
+it for years. `git-remote-e2ee` keeps that goal and fixes three things that
+make encrypted remotes painful to live with:
+
+- **Fast: incremental updates on any backend.** A push uploads only the new Git
+ pack plus a few KiB of metadata, including when GitHub or GitLab is the
+ storage. gcrypt re-uploads the entire history on every push to a Git or SFTP
+ backend, and can repack without warning.
+- **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
+ push is effectively a force push, so the second push can erase the first.
+- **No master key: every device has its own key.** There is no repository
+ password or shared private key to hand around. An administrator adds a
+ device by its public key and revokes it on its own, without rewriting
+ history. transcrypt shares one password, and git-crypt shares one repository
+ key that cannot be revoked.
+
+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. Storage backends today: a **local
+or mounted directory**, and **any ordinary Git remote** (GitHub, GitLab, a bare
+repository) used as a ciphertext carrier. The full comparison, including where
+other tools are the better choice, is in [How it compares](#how-it-compares).
## Quick start
From 0ee73b1346ab2ecaed253330644d6a96d934fe9f Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 18:36:36 +0900
Subject: [PATCH 11/98] Qualify the fast claim with the carrier re-clone caveat
---
README.ja.md | 9 +++++----
README.md | 33 +++++++++++++++++++++------------
docs/guide.md | 3 +++
3 files changed, 29 insertions(+), 16 deletions(-)
diff --git a/README.ja.md b/README.ja.md
index d91e8df..3f0d5c4 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -45,9 +45,9 @@ GitHub や GitLab のプライベートリポジトリは、事業者が読め
Git リポジトリ全体の暗号化自体は新しいものではなく、[`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt) が長年実現してきました。`git-remote-e2ee` はその目的を引き継ぎ、暗号化リモートを日常的に使ううえで困る点を3つ解決します。
-- **速い: どのバックエンドでも差分だけを転送します。** push でアップロードするのは新しい Git の pack と数 KiB のメタデータだけで、GitHub や GitLab を保存先にしても同じです。gcrypt は Git や SFTP のバックエンドでは push のたびに履歴全体を上げ直し、予告なく repack することもあります。
+- **速い: push では変わった分だけをアップロードします。** アップロードするのは新しい Git の pack と少しのメタデータだけで(端末1台でごく小さなコミットなら合計 3〜4 KiB)、どのバックエンドでも同じです。gcrypt は Git や SFTP のバックエンドでは push のたびに履歴全体を上げ直し、予告なく repack することもあります。ただし現時点では、Git ホストを保存先にすると操作のたびに暗号化された carrier リポジトリ全体を clone するため、そこではダウンロードはまだ差分になっていません。
- **安全: 黙って force push されません。** fast-forward の確認はクライアントで行い、保存先の `HEAD` は compare-and-swap でしか動きません。2人が同時に push すると片方が勝ち、もう片方は普通に拒否されます。gcrypt では push が実質すべて force push なので、後の push が先の push を消してしまうことがあります。
-- **マスターキーなし: 端末ごとに別の鍵を持ちます。** 配って回るリポジトリのパスワードや共有の秘密鍵はありません。管理者は公開鍵で端末を追加でき、履歴を書き換えずに1台ずつ取り消せます。transcrypt は1つのパスワードを共有し、git-crypt は取り消しのできない1つのリポジトリ鍵を共有します。
+- **マスターキーなし: 端末ごとに別の鍵を持ちます。** 配って回る長期のリポジトリパスワードや共有の秘密鍵はありません。公開のたびに新しいランダムな鍵を作り、許可された端末それぞれに個別に届けます。管理者は公開鍵で端末を追加でき、履歴を書き換えずに1台ずつ取り消せます。transcrypt は1つのパスワードを共有し、git-crypt は1つのリポジトリ鍵を共有していて、アクセスの取り消しに対応していません。
内部では、Git はいつもどおり手元で動きます。ヘルパーが Git から受け取った pack と ref を暗号化し、中身の見えない変更不可のオブジェクトとしてアップロードしてから、中身の見えない `HEAD` ポインタを1つだけアトミックに更新します。現在使える保存先は、**ローカルまたはマウントしたディレクトリ**と、暗号文の運び役として使う**通常の Git リモート**(GitHub、GitLab、bare リポジトリ)です。他のツールのほうが向いている場合も含めた詳しい比較は[他のツールとの比較](#他のツールとの比較)にあります。
@@ -110,7 +110,7 @@ Git を暗号化するツールには大きく2つの系統があり、解決し
| 共同作業者を1人だけ取り消せる | ×⁸ | × | △⁹ | ○¹⁰ |
| 読み取り・書き込み・管理の権限を分けられる | × | × | × | △¹¹ |
| 同時 push で互いの変更が黙って上書きされない | ○ | ○ | ×¹² | ○ |
-| 小さな更新は小さな転送で済む | △¹³ | △¹³ | △⁵ | ○ |
+| 小さな更新は小さな転送で済む | △¹³ | △¹³ | △⁵ | ○¹⁶ |
| リモートの巻き戻しや分岐を検知できる | × | × | × | △¹⁴ |
| タグとリモートブランチの削除 | ○ | ○ | ○ | ×¹⁵ |
| 成熟していて形式が安定している | ○ | ○ | ○ | × |
@@ -133,6 +133,7 @@ Git を暗号化するツールには大きく2つの系統があり、解決し
13. 暗号化したファイルは、変更すると前の版と無関係な暗号文になるため、Git が差分圧縮できません。
14. 一度同期したことのある clone は、巻き戻しや分岐した履歴を拒否します。まったく新しい clone は、外部のアンカーがないと見分けられません。[セキュリティモデル](#セキュリティモデル)を参照してください。
15. 現在 push できるのは `refs/heads/*` だけです。タグの push とブランチの削除は、何も公開せずに失敗します。
+16. アップロードはどのバックエンドでも差分です。Git ホストを保存先にした場合、現在の実装は操作ごとに carrier リポジトリ全体を clone するので、永続キャッシュが入るまでは fetch や push のたびに暗号化された履歴全体もダウンロードします。
### その他の方法
@@ -181,7 +182,7 @@ Git を暗号化するツールには大きく2つの系統があり、解決し
| ごく小さな push | 0.17 秒 | 0.54 秒 | 0.08 秒 |
| ごく小さな更新の fetch | 0.12 秒 | 0.50 秒 | 0.09 秒 |
-通常の Git とは単純に比べられません。Git サーバーはオブジェクトを索引し、fetch のたびに pack を作りますが、暗号化リモートは中身の見えない pack を保存してそのまま再生します。単純な保存先では効率的ですが、partial clone のようなサーバー側の機能は使えなくなります。更新ごとに保存されるのは新しい pack と数 KiB のメタデータだけで、読み手を1人追加しても既存の pack は1バイトも書き換わりませんでした。測定方法、その他のケース、制約は [docs/benchmarks.md](docs/benchmarks.md) にあります。
+通常の Git とは単純に比べられません。Git サーバーはオブジェクトを索引し、fetch のたびに pack を作りますが、暗号化リモートは中身の見えない pack を保存してそのまま再生します。単純な保存先では効率的ですが、partial clone のようなサーバー側の機能は使えなくなります。更新ごとに保存されるのは新しい pack とメタデータだけで、小さなコミットなら端末1台で合計 3〜4 KiB、読み手100人では1回の更新あたり約 30 KiB になります。読み手を1人追加しても、既存の pack は1バイトも書き換わりませんでした。これらはディレクトリのバックエンドでの数値で、Git carrier のバックエンドは現在、操作ごとに carrier を clone し直します。測定方法、その他のケース、制約は [docs/benchmarks.md](docs/benchmarks.md) にあります。
## よくある質問
diff --git a/README.md b/README.md
index d9d72ed..a55a79e 100644
--- a/README.md
+++ b/README.md
@@ -65,19 +65,22 @@ Encrypting a whole Git repository is not new;
it for years. `git-remote-e2ee` keeps that goal and fixes three things that
make encrypted remotes painful to live with:
-- **Fast: incremental updates on any backend.** A push uploads only the new Git
- pack plus a few KiB of metadata, including when GitHub or GitLab is the
- storage. gcrypt re-uploads the entire history on every push to a Git or SFTP
- backend, and can repack without warning.
+- **Fast: pushes upload only what changed.** A push uploads the new Git pack
+ plus a little metadata (3–4 KiB in total for a tiny commit with one
+ device), on every backend. gcrypt re-uploads the entire history on every push
+ to a Git or SFTP backend, and can repack without warning. One caveat today:
+ with a Git host as storage, each operation first clones the whole encrypted
+ carrier repository, so downloads are not yet incremental there.
- **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
push is effectively a force push, so the second push can erase the first.
-- **No master key: every device has its own key.** There is no repository
- password or shared private key to hand around. An administrator adds a
- device by its public key and revokes it on its own, without rewriting
- history. transcrypt shares one password, and git-crypt shares one repository
- key that cannot be revoked.
+- **No master key: every device has its own key.** There is no long-lived
+ repository password or shared private key to hand around. Each published
+ generation gets a fresh random key, delivered separately to each authorized
+ device. An administrator adds a device by its public key and revokes it on
+ its own, without rewriting history. transcrypt shares one password, and
+ git-crypt shares one repository key and does not support revoking access.
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
@@ -159,7 +162,7 @@ questions:
| Revoke one collaborator | ×⁸ | × | △⁹ | ○¹⁰ |
| Separate reader, writer, and administrator roles | × | × | × | △¹¹ |
| Concurrent pushes can't silently overwrite each other | ○ | ○ | ×¹² | ○ |
-| Small updates upload small amounts of data | △¹³ | △¹³ | △⁵ | ○ |
+| Small updates upload small amounts of data | △¹³ | △¹³ | △⁵ | ○¹⁶ |
| Detects a rolled-back or forked remote | × | × | × | △¹⁴ |
| Tags and remote branch deletion | ○ | ○ | ○ | ×¹⁵ |
| Mature, stable format | ○ | ○ | ○ | × |
@@ -202,6 +205,10 @@ questions:
[Security model](#security-model).
15. Pushes are limited to `refs/heads/*` for now. Tag pushes and branch
deletion fail without publishing anything.
+16. Uploads are incremental on every backend. With a Git host as storage, the
+ current implementation clones the whole carrier repository for each
+ operation, so every fetch and push also downloads the full encrypted
+ history until a persistent cache lands.
### Other approaches
@@ -276,8 +283,10 @@ single run:
Plain Git is not directly comparable. A Git server indexes objects and builds
packs per fetch, while encrypted remotes store and replay opaque packs. That is
cheap on dumb storage, but it rules out server-side features such as partial
-clone. Each update stores only its new pack plus a few KiB of metadata. Adding a
-reader rewrote zero existing pack bytes. See [docs/benchmarks.md](docs/benchmarks.md) for
+clone. Each update stores only its new pack plus metadata: 3–4 KiB in total with one
+device, growing to roughly 30 KiB per update at 100 readers. Adding a reader
+rewrote zero existing pack bytes. These numbers are for the directory backend;
+the Git-carrier backend currently re-clones the carrier on each operation. See [docs/benchmarks.md](docs/benchmarks.md) for
the method, the remaining cases, and the limitations.
## FAQ
diff --git a/docs/guide.md b/docs/guide.md
index 2c735fb..7110c75 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -155,6 +155,9 @@ compare-and-swap race. Inspect the winning state and run the command again.
deletion fail without publishing anything.
- A push that loses a race with another writer fails. Fetch, integrate, and
push again.
+- With a Git host as storage, each operation clones the whole carrier
+ repository into a temporary directory. Uploads are incremental, but downloads
+ grow with the total encrypted history until a persistent cache lands.
- Shallow and partial clones are not supported. A fresh clone downloads and
verifies the complete history.
From aaa351d739649a5702356df4927497a2ebaf5245 Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 18:42:43 +0900
Subject: [PATCH 12/98] Compare with git-crypt and git-remote-gcrypt only
Drop transcrypt from the tables, reframe the third advantage as managed
membership since gcrypt already uses per-participant keys, and replace
the fetch row with a GitHub-storage caveat for both encrypted remotes.
---
README.ja.md | 106 ++++++++++++++-----------------
README.md | 175 ++++++++++++++++++++++-----------------------------
2 files changed, 124 insertions(+), 157 deletions(-)
diff --git a/README.ja.md b/README.ja.md
index 3f0d5c4..5bb329d 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -43,13 +43,13 @@ GitHub や GitLab のプライベートリポジトリは、事業者が読め
## git-remote-e2ee の特長
-Git リポジトリ全体の暗号化自体は新しいものではなく、[`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt) が長年実現してきました。`git-remote-e2ee` はその目的を引き継ぎ、暗号化リモートを日常的に使ううえで困る点を3つ解決します。
+Git リポジトリ全体の暗号化自体は新しいものではなく、[`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt) が GnuPG を使って長年実現してきました。`git-remote-e2ee` はその目的を引き継ぎ、暗号化リモートを日常的に使ううえで困る点を解決します。
-- **速い: push では変わった分だけをアップロードします。** アップロードするのは新しい Git の pack と少しのメタデータだけで(端末1台でごく小さなコミットなら合計 3〜4 KiB)、どのバックエンドでも同じです。gcrypt は Git や SFTP のバックエンドでは push のたびに履歴全体を上げ直し、予告なく repack することもあります。ただし現時点では、Git ホストを保存先にすると操作のたびに暗号化された carrier リポジトリ全体を clone するため、そこではダウンロードはまだ差分になっていません。
-- **安全: 黙って force push されません。** fast-forward の確認はクライアントで行い、保存先の `HEAD` は compare-and-swap でしか動きません。2人が同時に push すると片方が勝ち、もう片方は普通に拒否されます。gcrypt では push が実質すべて force push なので、後の push が先の push を消してしまうことがあります。
-- **マスターキーなし: 端末ごとに別の鍵を持ちます。** 配って回る長期のリポジトリパスワードや共有の秘密鍵はありません。公開のたびに新しいランダムな鍵を作り、許可された端末それぞれに個別に届けます。管理者は公開鍵で端末を追加でき、履歴を書き換えずに1台ずつ取り消せます。transcrypt は1つのパスワードを共有し、git-crypt は1つのリポジトリ鍵を共有していて、アクセスの取り消しに対応していません。
+- **速い: push では変わった分だけをアップロードします。** アップロードするのは新しい Git の pack と少しのメタデータだけで(端末1台でごく小さなコミットなら合計 3〜4 KiB)、どのバックエンドでも同じです。gcrypt は GitHub のような Git や SFTP のバックエンドでは push のたびに履歴全体を上げ直し、予告なく repack することもあります。ただし現時点では、Git ホストを保存先にすると操作のたびに暗号化された carrier リポジトリ全体を clone するため、そこではダウンロードはまだ差分になっていません。
+- **安全: 黙って force push されません。** fast-forward の確認はクライアントで行い、保存先の `HEAD` は compare-and-swap でしか動きません。2人が同時に push すると片方が勝ち、もう片方は普通に拒否されます。gcrypt では push が実質すべて force push なので、pull せずに push すると他の人の変更を消してしまうことがあります。
+- **メンバーを管理できる: 端末ごとに1つの鍵、GPG は不要です。** gcrypt と同じく共有パスフレーズはなく、端末ごとに別の鍵を持ちます。gcrypt と違い、アクセスできる人を変えられるのは管理者だけです。端末の取り消しは組み込みの操作で、履歴を書き換えずに新しい鍵を発行します。また、一度同期した clone は、巻き戻されたリモートや分岐したリモートを拒否します。
-内部では、Git はいつもどおり手元で動きます。ヘルパーが Git から受け取った pack と ref を暗号化し、中身の見えない変更不可のオブジェクトとしてアップロードしてから、中身の見えない `HEAD` ポインタを1つだけアトミックに更新します。現在使える保存先は、**ローカルまたはマウントしたディレクトリ**と、暗号文の運び役として使う**通常の Git リモート**(GitHub、GitLab、bare リポジトリ)です。他のツールのほうが向いている場合も含めた詳しい比較は[他のツールとの比較](#他のツールとの比較)にあります。
+内部では、Git はいつもどおり手元で動きます。ヘルパーが Git から受け取った pack と ref を暗号化し、中身の見えない変更不可のオブジェクトとしてアップロードしてから、中身の見えない `HEAD` ポインタを1つだけアトミックに更新します。現在使える保存先は、**ローカルまたはマウントしたディレクトリ**と、暗号文の運び役として使う**通常の Git リモート**(GitHub、GitLab、bare リポジトリ)です。
## クイックスタート
@@ -79,42 +79,41 @@ git push -u private main
## 他のツールとの比較
-Git を暗号化するツールには大きく2つの系統があり、解決している問題が違います。
-
-- **ファイルフィルタ型**([`git-crypt`](https://github.com/AGWA/git-crypt)、[`transcrypt`](https://github.com/elasticdog/transcrypt))は、普通のリポジトリの中で、選んだファイルの中身だけを暗号化します。リポジトリの構造と履歴はホストから見えますが、暗号化していない部分については普段どおりホストの機能が使えます。
-- **暗号化リモート型**([`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt)、`git-remote-e2ee`)は、履歴を含めてリポジトリ全体を暗号化します。ホストに見えるのは中身の読めないデータだけで、内側のリポジトリについては何もわかりません。
+Git を暗号化するツールには2つの系統があります。**ファイル単位のツール**(ここでは代表として [`git-crypt`](https://github.com/AGWA/git-crypt))は、普通のリポジトリの中で、選んだファイルの中身だけを暗号化します。**暗号化リモート**([`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt) と `git-remote-e2ee`)は、履歴を含めてリポジトリ全体を暗号化します。
### 保存先に何がわかるか
-| | 通常のプライベートリポジトリ | `git-crypt` | `transcrypt` | `git-remote-gcrypt` | `git-remote-e2ee` |
-|---|:---:|:---:|:---:|:---:|:---:|
-| 保護対象に選んだファイルの中身 | 見える | 隠れる | 隠れる | 隠れる | 隠れる |
-| それ以外のファイルの中身 | 見える | 見える | 見える | 隠れる | 隠れる |
-| ファイル名とディレクトリ構成 | 見える | 見える | 見える | 隠れる | 隠れる |
-| コミットメッセージ・作者・日時 | 見える | 見える | 見える | 隠れる | 隠れる |
-| ブランチ名とコミットグラフ | 見える | 見える | 見える | 隠れる | 隠れる |
-| 変更されたファイル・サイズ・同一ファイル | 見える | 見える¹ | 見える | 隠れる | 隠れる |
-| push した時刻とおおよその量 | 見える | 見える | 見える | 見える | 見える |
-| 共同作業者の鍵 | アカウント一覧 | 鍵のフィンガープリント² | —(共有パスワード) | 人数のみ³ | 人数・公開鍵・権限⁴ |
+| | 通常のプライベートリポジトリ | `git-crypt` | `git-remote-gcrypt` | `git-remote-e2ee` |
+|---|:---:|:---:|:---:|:---:|
+| 保護対象に選んだファイルの中身 | 見える | 隠れる | 隠れる | 隠れる |
+| それ以外のファイルの中身 | 見える | 見える | 隠れる | 隠れる |
+| ファイル名とディレクトリ構成 | 見える | 見える | 隠れる | 隠れる |
+| コミットメッセージ・作者・日時 | 見える | 見える | 隠れる | 隠れる |
+| ブランチ名とコミットグラフ | 見える | 見える | 隠れる | 隠れる |
+| 変更されたファイル・サイズ・同一ファイル | 見える | 見える¹ | 隠れる | 隠れる |
+| push した時刻とおおよその量 | 見える | 見える | 見える | 見える |
+| 共同作業者 | アカウント一覧 | 鍵のフィンガープリント² | 人数のみ³ | 人数・公開鍵・権限⁴ |
### 各ツールでできること
-| | `git-crypt` | `transcrypt` | `git-remote-gcrypt` | `git-remote-e2ee` |
-|---|:---:|:---:|:---:|:---:|
-| 通常の `clone`・`pull`・`push` | ○ | ○ | ○ | ○ |
-| GitHub・GitLab を保存先にできる | ○ | ○ | △⁵ | ○ |
-| 暗号化していない部分はホストで差分・レビュー・検索ができる | ○ | ○ | × | × |
-| 一部のファイルだけ暗号化できる | ○ | ○ | × | × |
-| 改ざんを検知できる | △⁶ | ×⁷ | ○ | ○ |
-| 共有の秘密なしで、公開鍵で人を追加できる | ○² | × | ○ | ○ |
-| 共同作業者を1人だけ取り消せる | ×⁸ | × | △⁹ | ○¹⁰ |
-| 読み取り・書き込み・管理の権限を分けられる | × | × | × | △¹¹ |
-| 同時 push で互いの変更が黙って上書きされない | ○ | ○ | ×¹² | ○ |
-| 小さな更新は小さな転送で済む | △¹³ | △¹³ | △⁵ | ○¹⁶ |
-| リモートの巻き戻しや分岐を検知できる | × | × | × | △¹⁴ |
-| タグとリモートブランチの削除 | ○ | ○ | ○ | ×¹⁵ |
-| 成熟していて形式が安定している | ○ | ○ | ○ | × |
-| 暗号方式 | AES-256-CTR、HMAC-SHA1 SIV | OpenSSL の AES-256-CBC | OpenPGP(GnuPG) | XChaCha20-Poly1305、HPKE(X25519)、Ed25519 |
+| | `git-crypt` | `git-remote-gcrypt` | `git-remote-e2ee` |
+|---|:---:|:---:|:---:|
+| 通常の `clone`・`pull`・`push` | ○ | ○ | ○ |
+| GitHub・GitLab を保存先にできる | ○ | △⁵ | △⁶ |
+| 暗号化していない部分はホストで差分・レビュー・検索ができる | ○ | × | × |
+| 一部のファイルだけ暗号化できる | ○ | × | × |
+| push では新しいデータだけをアップロードする | △⁷ | △⁵ | ○ |
+| 同時 push で互いの変更が黙って上書きされない | ○ | ×⁸ | ○ |
+| 改ざんを検知できる | △⁹ | ○ | ○ |
+| 人・端末ごとに別の鍵で、共有パスフレーズがない | △¹⁰ | ○ | ○ |
+| メンバーを変えられるのは管理者だけ | × | ×¹¹ | ○ |
+| 共同作業者を1人だけ取り消せる | ×¹² | ×¹³ | ○¹⁴ |
+| 読み取りと書き込みの権限を分けられる | × | × | △¹⁵ |
+| リモートの巻き戻しや分岐を検知できる | × | × | △¹⁶ |
+| GnuPG なしで使える | ○¹⁰ | × | ○ |
+| タグとリモートブランチの削除 | ○ | ○ | ×¹⁷ |
+| 成熟していて形式が安定している | ○ | ○ | × |
+| 暗号方式 | AES-256-CTR、HMAC-SHA1 SIV | OpenPGP(GnuPG) | XChaCha20-Poly1305、HPKE(X25519)、Ed25519 |
○ 対応 · △ 条件付き · × 非対応
@@ -123,33 +122,24 @@ Git を暗号化するツールには大きく2つの系統があり、解決し
3. gcrypt は既定で受信者の鍵 ID を隠します(`gpg -R`)。暗号化された鍵パケットの数はわかります。
4. 各端末は復号の前に自分宛ての封筒を見つける必要があるため、ポリシーは平文の構造を持ちます。そのため保存先には、端末の数、リポジトリごとの公開鍵、権限、ポリシーの変更が見えます。端末鍵をリポジトリ間で使い回さないでください。
5. gcrypt はローカルと rsync のバックエンドでは差分転送ですが、[ドキュメント](https://manpages.debian.org/trixie/git-remote-gcrypt/git-remote-gcrypt.1.en.html)によると Git や SFTP のバックエンドでは push のたびに履歴全体をアップロードします。また、予告なくリモートを repack することがあります。
-6. git-crypt は暗号化したファイルの中身を認証しますが、ファイル名、履歴、どのファイルが暗号化されているかは通常の Git データです。
-7. transcrypt は認証のない CBC を使っており、README で暗号文の malleability を既知の制約として挙げています。
-8. git-crypt の README に、アクセスの取り消しには対応していないと明記されています。鍵を替えるには、保護したファイルを手作業で暗号化し直す必要があります。
-9. `gcrypt.participants` から参加者を外すことはできますが、取り消しの手順は文書化されていません。
-10. 取り消すと、履歴を書き換えずに新しい鍵が発行されます。ここに挙げたどのツールでも、その端末がすでに持っているデータは取り戻せません。
-11. プロトコル上は3つの権限を分けていますが、現在の CLI で付与できるのは「読み書き」か「読み書きと管理」のどちらかです。
-12. gcrypt の push は実質的にすべて force push です。明示的な force を求めるオプションは事故を防ぎますが、同時に書き込む人同士の調整はしません。
-13. 暗号化したファイルは、変更すると前の版と無関係な暗号文になるため、Git が差分圧縮できません。
-14. 一度同期したことのある clone は、巻き戻しや分岐した履歴を拒否します。まったく新しい clone は、外部のアンカーがないと見分けられません。[セキュリティモデル](#セキュリティモデル)を参照してください。
-15. 現在 push できるのは `refs/heads/*` だけです。タグの push とブランチの削除は、何も公開せずに失敗します。
-16. アップロードはどのバックエンドでも差分です。Git ホストを保存先にした場合、現在の実装は操作ごとに carrier リポジトリ全体を clone するので、永続キャッシュが入るまでは fetch や push のたびに暗号化された履歴全体もダウンロードします。
-
-### その他の方法
-
-- **Keybase の暗号化 Git** はエンドツーエンド暗号化されていますが、Keybase のサービスでしか使えません。
-- **bare リポジトリを暗号化ファイルシステムの上に置く**方法(rclone crypt、Cryptomator、gocryptfs)は、中身は隠せますが、リポジトリのファイル構成、サイズ、更新のパターンは隠せません。同期サービス経由で2台から同時に push すると、リポジトリが壊れることもあります。
-- **git-annex の暗号化 special remote** が守るのは annex された大きなファイルで、Git の履歴そのものではありません。
+6. push でアップロードするのは新しいデータだけですが、現在の実装は操作ごとに carrier リポジトリ全体を clone するので、永続キャッシュが入るまでは fetch や push のたびに暗号化された履歴全体もダウンロードします。ディレクトリを保存先にした場合は、どちら向きも差分です。
+7. 暗号化したファイルは、変更すると前の版と無関係な暗号文になるため、Git が差分圧縮できません。
+8. gcrypt の push は実質的にすべて force push です。明示的な force を求めるオプションは事故を防ぎますが、同時に書き込む人同士の調整はしません。
+9. git-crypt は暗号化したファイルの中身を認証しますが、ファイル名、履歴、どのファイルが暗号化されているかは通常の Git データです。
+10. git-crypt の GPG モードは、1つの共通鍵であるリポジトリ鍵を利用者それぞれの GPG 鍵で包みます。GPG を使わない場合は、全員が同じ鍵ファイルを共有します。
+11. gcrypt の受信者リストは push する人のローカル設定 `gcrypt.participants` なので、push できる参加者なら誰でも、次の状態を誰が読めるかを決められます。
+12. git-crypt の README に、アクセスの取り消しには対応していないと明記されています。
+13. `gcrypt.participants` から参加者を外すことはできますが、取り消しの手順は文書化されていません。
+14. 取り消すと、履歴を書き換えずに新しい鍵が発行されます。その端末がすでに持っているデータは取り戻せません。
+15. プロトコル上は読み取り・書き込み・管理の権限を分けていますが、現在の CLI で付与できるのは「読み書き」か「読み書きと管理」のどちらかです。
+16. 一度同期したことのある clone は、巻き戻しや分岐した履歴を拒否します。まったく新しい clone は、外部のアンカーがないと見分けられません。[セキュリティモデル](#セキュリティモデル)を参照してください。
+17. 現在 push できるのは `refs/heads/*` だけです。タグの push とブランチの削除は、何も公開せずに失敗します。
### 他のツールのほうが向いている場合
-- **git-crypt**: 秘密にしたいのが一部のファイルだけで、残りは GitHub や GitLab で普段どおり扱いたい。
-- **transcrypt**: 同上で、共有パスワードとシェルだけの簡単な構成で構わない。
-- **git-remote-gcrypt**: 実績のあるリポジトリ全体の暗号化ツールがほしく、すでに GPG を使っていて、ローカルか rsync のバックエンドを使える。
+- **git-remote-gcrypt**: 形式が安定した実績あるツールがほしく、すでに GPG を使っていて、1人で使うか push のタイミングを調整できる。
+- **git-crypt などファイル単位のツール**: 秘密にしたいのが一部のファイルだけで、残りは GitHub や GitLab で普段どおり扱いたい。
- **通常のプライベートリポジトリ**: ホスティング先を信頼でき、プルリクエスト、検索、プレビュー、CI を使いたい。
-- **成熟したもの全般**: 安定した形式、復旧ツール、タグ、shallow clone が今すぐ必要。
-
-`git-remote-e2ee` を選ぶのは、リポジトリ全体と履歴を保存先から隠す必要があり、共同作業者がそれぞれ自分の鍵を持つべきで、信頼できない保存先でも Git の普段どおりの競合の扱いがほしいときです。
## セキュリティモデル
diff --git a/README.md b/README.md
index a55a79e..e63db13 100644
--- a/README.md
+++ b/README.md
@@ -60,34 +60,33 @@ if every authorized key is lost, the data cannot be recovered by anyone.
## Why git-remote-e2ee
-Encrypting a whole Git repository is not new;
+Encrypting a whole Git repository is not new:
[`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt) has done
-it for years. `git-remote-e2ee` keeps that goal and fixes three things that
-make encrypted remotes painful to live with:
+it with GnuPG for years. `git-remote-e2ee` keeps that goal and fixes what makes
+an encrypted remote painful to live with:
- **Fast: pushes upload only what changed.** A push uploads the new Git pack
- plus a little metadata (3–4 KiB in total for a tiny commit with one
- device), on every backend. gcrypt re-uploads the entire history on every push
- to a Git or SFTP backend, and can repack without warning. One caveat today:
- with a Git host as storage, each operation first clones the whole encrypted
- carrier repository, so downloads are not yet incremental there.
+ plus a little metadata (3–4 KiB in total for a tiny commit with one device),
+ on every backend. gcrypt re-uploads the entire history on every push to a Git
+ or SFTP backend such as GitHub, and can repack without warning. One caveat
+ today: with a Git host as storage, each operation first clones the whole
+ encrypted carrier repository, so downloads are not yet incremental there.
- **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
- push is effectively a force push, so the second push can erase the first.
-- **No master key: every device has its own key.** There is no long-lived
- repository password or shared private key to hand around. Each published
- generation gets a fresh random key, delivered separately to each authorized
- device. An administrator adds a device by its public key and revokes it on
- its own, without rewriting history. transcrypt shares one password, and
- git-crypt shares one repository key and does not support revoking access.
+ push is effectively a force push, so a push made without pulling first can
+ erase someone else's work.
+- **Managed membership: one key per device, no GPG.** Like gcrypt, there is no
+ shared passphrase: every device has its own key. Unlike gcrypt, only
+ administrators can change who has access, revoking a device is a built-in
+ operation that publishes a fresh key without rewriting history, and a clone
+ that has synced before rejects a rolled-back or forked remote.
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. Storage backends today: a **local
or mounted directory**, and **any ordinary Git remote** (GitHub, GitLab, a bare
-repository) used as a ciphertext carrier. The full comparison, including where
-other tools are the better choice, is in [How it compares](#how-it-compares).
+repository) used as a ciphertext carrier.
## Quick start
@@ -123,50 +122,45 @@ the directory backend, cloning, and revoking devices.
## How it compares
-There are two families of encrypted-Git tools, and they answer different
-questions:
-
-- **File filters** ([`git-crypt`](https://github.com/AGWA/git-crypt),
- [`transcrypt`](https://github.com/elasticdog/transcrypt)) encrypt the contents
- of selected files inside an otherwise normal repository. The host still sees
- the repository's structure and history, and keeps working normally for
- everything left unencrypted.
-- **Encrypted remotes**
- ([`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt),
- `git-remote-e2ee`) encrypt the whole repository, history included. The host
- sees opaque blobs and nothing else of the inner repository.
+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` | `transcrypt` | `git-remote-gcrypt` | `git-remote-e2ee` |
-|---|:---:|:---:|:---:|:---:|:---:|
-| Contents of files you chose to protect | visible | hidden | hidden | hidden | hidden |
-| Contents of all other files | visible | visible | visible | hidden | hidden |
-| File names and directory layout | visible | visible | visible | hidden | hidden |
-| Commit messages, authors, and dates | visible | visible | visible | hidden | hidden |
-| Branch names and the commit graph | visible | visible | visible | hidden | hidden |
-| Which files changed, their sizes, identical files | visible | visible¹ | visible | hidden | hidden |
-| When you push, and roughly how much | visible | visible | visible | visible | visible |
-| Collaborator keys | account list | key fingerprints² | — (shared password) | count only³ | count, public keys, roles⁴ |
+| | 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, public keys, roles⁴ |
### What each tool can do
-| | `git-crypt` | `transcrypt` | `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 | ○ | ○ | × | × |
-| Tampering detected | △⁶ | ×⁷ | ○ | ○ |
-| No shared secret; add people by public key | ○² | × | ○ | ○ |
-| Revoke one collaborator | ×⁸ | × | △⁹ | ○¹⁰ |
-| Separate reader, writer, and administrator roles | × | × | × | △¹¹ |
-| Concurrent pushes can't silently overwrite each other | ○ | ○ | ×¹² | ○ |
-| Small updates upload small amounts of data | △¹³ | △¹³ | △⁵ | ○¹⁶ |
-| Detects a rolled-back or forked remote | × | × | × | △¹⁴ |
-| Tags and remote branch deletion | ○ | ○ | ○ | ×¹⁵ |
-| Mature, stable format | ○ | ○ | ○ | × |
-| Cryptography | AES-256-CTR, HMAC-SHA1 SIV | AES-256-CBC via OpenSSL | OpenPGP (GnuPG) | XChaCha20-Poly1305, HPKE (X25519), Ed25519 |
+| | `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 | △⁹ | ○ | ○ |
+| One key per person or device, no shared passphrase | △¹⁰ | ○ | ○ |
+| Only administrators can change membership | × | ×¹¹ | ○ |
+| Revoke one collaborator | ×¹² | ×¹³ | ○¹⁴ |
+| Separate reader and writer roles | × | × | △¹⁵ |
+| Detects a rolled-back or forked remote | × | × | △¹⁶ |
+| Works without GnuPG | ○¹⁰ | × | ○ |
+| 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
@@ -184,59 +178,42 @@ questions:
[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.
-6. git-crypt authenticates encrypted file contents; file names, history, and
+6. Pushes upload only new data, but the current implementation clones the whole
+ carrier repository for each operation, so every fetch and push also
+ downloads the full encrypted history until a persistent cache lands. With a
+ directory as storage, both directions are incremental.
+7. A changed encrypted file becomes a new, unrelated ciphertext blob, so Git
+ cannot delta-compress it against the previous version.
+8. Every gcrypt push is effectively a force push. Its explicit-force option
+ prevents accidents but does not coordinate concurrent writers.
+9. git-crypt authenticates encrypted file contents; file names, history, and
which files are encrypted are ordinary Git data.
-7. transcrypt uses unauthenticated CBC; its README lists ciphertext
- malleability as a known limitation.
-8. git-crypt's README states that it does not support revoking access.
- Rotating the key means re-encrypting the protected files by hand.
-9. Participants can be removed from `gcrypt.participants`, but gcrypt does not
- document a revocation workflow.
-10. Revocation publishes a fresh key without rewriting history. Like every tool
- here, it cannot take back data the device already had.
-11. The protocol separates the three roles, but the CLI currently grants either
- read and write, or read, write, and administration.
-12. Every gcrypt push is effectively a force push. Its explicit-force option
- prevents accidents but does not coordinate concurrent writers.
-13. A changed encrypted file becomes a new, unrelated ciphertext blob, so Git
- cannot delta-compress it against the previous version.
-14. A clone that has synced before rejects rollback or a diverging history. A
+10. git-crypt's GPG mode wraps one symmetric repository key to each user's GPG
+ key. Without GPG, everyone shares the same exported key file.
+11. 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.
+12. git-crypt's README states that it does not support revoking access.
+13. A participant can be dropped from `gcrypt.participants`, but gcrypt does
+ not document a revocation workflow.
+14. Revocation publishes a fresh key without rewriting history. It cannot take
+ back data the device already had.
+15. The protocol separates reader, writer, and administrator, but the CLI
+ currently grants either read and write, or read, write, and administration.
+16. 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. Pushes are limited to `refs/heads/*` for now. Tag pushes and branch
+17. Pushes are limited to `refs/heads/*` for now. Tag pushes and branch
deletion fail without publishing anything.
-16. Uploads are incremental on every backend. With a Git host as storage, the
- current implementation clones the whole carrier repository for each
- operation, so every fetch and push also downloads the full encrypted
- history until a persistent cache lands.
-
-### Other approaches
-
-- **Keybase encrypted Git** is end-to-end encrypted, but it only works with
- Keybase's own service.
-- **An encrypted filesystem under a bare repository** (rclone crypt,
- Cryptomator, gocryptfs) hides contents, but not the repository's file
- structure, sizes, or update pattern. Two machines pushing at once through a
- sync service can also corrupt the repository.
-- **git-annex encrypted special remotes** protect large annexed files, not the
- Git history itself.
### When another tool is a better fit
-- **git-crypt**: only a few files are secret, and you want GitHub or GitLab to
- keep working normally for the rest.
-- **transcrypt**: the same, and a shared password with a simple shell setup is
- acceptable.
-- **git-remote-gcrypt**: you want an established whole-repository tool, already
- use GPG, and can use its local or rsync backend.
+- **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.
-- **Anything mature**: you need a stable format, recovery tooling, tags, or
- shallow clones today.
-
-Choose `git-remote-e2ee` when the whole repository and its history must be
-opaque to storage, each collaborator should hold their own key, and you want
-Git's normal conflict behavior on storage you do not trust.
## Security model
From d294857dbc0823d4026883868a08620af543cdc6 Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 18:44:43 +0900
Subject: [PATCH 13/98] Restore the no-master-key advantage with gcrypt's
actual key model
---
README.ja.md | 5 +++--
README.md | 19 +++++++++++++------
2 files changed, 16 insertions(+), 8 deletions(-)
diff --git a/README.ja.md b/README.ja.md
index 5bb329d..17703d4 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -47,7 +47,7 @@ Git リポジトリ全体の暗号化自体は新しいものではなく、[`gi
- **速い: push では変わった分だけをアップロードします。** アップロードするのは新しい Git の pack と少しのメタデータだけで(端末1台でごく小さなコミットなら合計 3〜4 KiB)、どのバックエンドでも同じです。gcrypt は GitHub のような Git や SFTP のバックエンドでは push のたびに履歴全体を上げ直し、予告なく repack することもあります。ただし現時点では、Git ホストを保存先にすると操作のたびに暗号化された carrier リポジトリ全体を clone するため、そこではダウンロードはまだ差分になっていません。
- **安全: 黙って force push されません。** fast-forward の確認はクライアントで行い、保存先の `HEAD` は compare-and-swap でしか動きません。2人が同時に push すると片方が勝ち、もう片方は普通に拒否されます。gcrypt では push が実質すべて force push なので、pull せずに push すると他の人の変更を消してしまうことがあります。
-- **メンバーを管理できる: 端末ごとに1つの鍵、GPG は不要です。** gcrypt と同じく共有パスフレーズはなく、端末ごとに別の鍵を持ちます。gcrypt と違い、アクセスできる人を変えられるのは管理者だけです。端末の取り消しは組み込みの操作で、履歴を書き換えずに新しい鍵を発行します。また、一度同期した clone は、巻き戻されたリモートや分岐したリモートを拒否します。
+- **マスターキーなし: 端末ごとに別の鍵を持ちます。** gcrypt は GPG 鍵に向けて暗号化し、既定では自分の鍵1つが対象です。そのため複数のマシンで使うには、普通は同じ GPG 秘密鍵を全マシンにコピーします。その鍵1つで履歴全体も今後の push もすべて開けられ、ノートPCを1台なくしてもその端末だけを切り離すことはできません。`git-remote-e2ee` は端末ごとに別の鍵を持ち、GPG も不要です。アクセスできる人を変えられるのは管理者だけで、端末を取り消すと履歴を書き換えずに新しい鍵を発行します。
内部では、Git はいつもどおり手元で動きます。ヘルパーが Git から受け取った pack と ref を暗号化し、中身の見えない変更不可のオブジェクトとしてアップロードしてから、中身の見えない `HEAD` ポインタを1つだけアトミックに更新します。現在使える保存先は、**ローカルまたはマウントしたディレクトリ**と、暗号文の運び役として使う**通常の Git リモート**(GitHub、GitLab、bare リポジトリ)です。
@@ -105,7 +105,7 @@ Git を暗号化するツールには2つの系統があります。**ファイ
| push では新しいデータだけをアップロードする | △⁷ | △⁵ | ○ |
| 同時 push で互いの変更が黙って上書きされない | ○ | ×⁸ | ○ |
| 改ざんを検知できる | △⁹ | ○ | ○ |
-| 人・端末ごとに別の鍵で、共有パスフレーズがない | △¹⁰ | ○ | ○ |
+| 端末ごとに別の鍵を持つ | △¹⁰ | △¹⁸ | ○ |
| メンバーを変えられるのは管理者だけ | × | ×¹¹ | ○ |
| 共同作業者を1人だけ取り消せる | ×¹² | ×¹³ | ○¹⁴ |
| 読み取りと書き込みの権限を分けられる | × | × | △¹⁵ |
@@ -134,6 +134,7 @@ Git を暗号化するツールには2つの系統があります。**ファイ
15. プロトコル上は読み取り・書き込み・管理の権限を分けていますが、現在の CLI で付与できるのは「読み書き」か「読み書きと管理」のどちらかです。
16. 一度同期したことのある clone は、巻き戻しや分岐した履歴を拒否します。まったく新しい clone は、外部のアンカーがないと見分けられません。[セキュリティモデル](#セキュリティモデル)を参照してください。
17. 現在 push できるのは `refs/heads/*` だけです。タグの push とブランチの削除は、何も公開せずに失敗します。
+18. gcrypt は push ごとに新しい pack 鍵で暗号化し、すべての pack 鍵を manifest に列挙して、`gcrypt.participants` のすべての GPG 鍵に向けて暗号化します。この設定がなければ既定の GPG 鍵1つに向けて暗号化するので、複数のマシンは普通1つの秘密鍵を共有します。マシンごとに別の GPG 鍵を並べることもできますが、それを管理・取り消しする手順は gcrypt にはありません。
### 他のツールのほうが向いている場合
diff --git a/README.md b/README.md
index e63db13..bdb6bcf 100644
--- a/README.md
+++ b/README.md
@@ -76,11 +76,13 @@ an encrypted remote painful to live with:
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.
-- **Managed membership: one key per device, no GPG.** Like gcrypt, there is no
- shared passphrase: every device has its own key. Unlike gcrypt, only
- administrators can change who has access, revoking a device is a built-in
- operation that publishes a fresh key without rewriting history, and a clone
- that has synced before rejects a rolled-back or forked remote.
+- **No master key: every device has its own key.** gcrypt encrypts to GPG keys,
+ by default to your own, so using it from several machines usually means
+ copying one GPG private key to all of them. That one key opens the whole
+ history and every future push, and a lost laptop cannot be cut off on its
+ own. `git-remote-e2ee` gives each device its own key and needs no GPG. Only
+ administrators can change who has access, and revoking a device publishes a
+ fresh key without rewriting history.
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
@@ -152,7 +154,7 @@ of selected files inside an otherwise normal repository. **Encrypted remotes**,
| Push uploads only new data | △⁷ | △⁵ | ○ |
| Concurrent pushes can't silently overwrite each other | ○ | ×⁸ | ○ |
| Tampering detected | △⁹ | ○ | ○ |
-| One key per person or device, no shared passphrase | △¹⁰ | ○ | ○ |
+| A separate key for every device | △¹⁰ | △¹⁸ | ○ |
| Only administrators can change membership | × | ×¹¹ | ○ |
| Revoke one collaborator | ×¹² | ×¹³ | ○¹⁴ |
| Separate reader and writer roles | × | × | △¹⁵ |
@@ -205,6 +207,11 @@ of selected files inside an otherwise normal repository. **Encrypted remotes**,
[Security model](#security-model).
17. Pushes are limited to `refs/heads/*` for now. Tag pushes and branch
deletion fail without publishing anything.
+18. gcrypt encrypts each push with a fresh pack key and lists all pack keys in
+ a manifest encrypted to every GPG key in `gcrypt.participants`. Without
+ that setting it encrypts to your default GPG key, so several machines
+ normally share one private key. Listing a separate GPG key per machine is
+ possible, but gcrypt provides no workflow for managing or revoking them.
### When another tool is a better fit
From 17cb64549364a97502cbbe2e6e7e335f824030cb Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 18:49:28 +0900
Subject: [PATCH 14/98] Frame no master key against file-level encryption
---
README.ja.md | 9 ++++-----
README.md | 34 +++++++++++++++-------------------
2 files changed, 19 insertions(+), 24 deletions(-)
diff --git a/README.ja.md b/README.ja.md
index 17703d4..ea354aa 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -43,11 +43,11 @@ GitHub や GitLab のプライベートリポジトリは、事業者が読め
## git-remote-e2ee の特長
-Git リポジトリ全体の暗号化自体は新しいものではなく、[`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt) が GnuPG を使って長年実現してきました。`git-remote-e2ee` はその目的を引き継ぎ、暗号化リモートを日常的に使ううえで困る点を解決します。
+Git の暗号化自体は新しいものではありません。[`git-crypt`](https://github.com/AGWA/git-crypt) のようなファイル単位のツールは選んだファイルを暗号化し、[`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt) は GnuPG を使ってリポジトリ全体の暗号化を長年実現してきました。`git-remote-e2ee` は gcrypt と同じくリポジトリ全体を隠したうえで、日常的に使ううえで困る点を解決します。
- **速い: push では変わった分だけをアップロードします。** アップロードするのは新しい Git の pack と少しのメタデータだけで(端末1台でごく小さなコミットなら合計 3〜4 KiB)、どのバックエンドでも同じです。gcrypt は GitHub のような Git や SFTP のバックエンドでは push のたびに履歴全体を上げ直し、予告なく repack することもあります。ただし現時点では、Git ホストを保存先にすると操作のたびに暗号化された carrier リポジトリ全体を clone するため、そこではダウンロードはまだ差分になっていません。
- **安全: 黙って force push されません。** fast-forward の確認はクライアントで行い、保存先の `HEAD` は compare-and-swap でしか動きません。2人が同時に push すると片方が勝ち、もう片方は普通に拒否されます。gcrypt では push が実質すべて force push なので、pull せずに push すると他の人の変更を消してしまうことがあります。
-- **マスターキーなし: 端末ごとに別の鍵を持ちます。** gcrypt は GPG 鍵に向けて暗号化し、既定では自分の鍵1つが対象です。そのため複数のマシンで使うには、普通は同じ GPG 秘密鍵を全マシンにコピーします。その鍵1つで履歴全体も今後の push もすべて開けられ、ノートPCを1台なくしてもその端末だけを切り離すことはできません。`git-remote-e2ee` は端末ごとに別の鍵を持ち、GPG も不要です。アクセスできる人を変えられるのは管理者だけで、端末を取り消すと履歴を書き換えずに新しい鍵を発行します。
+- **マスターキーなし: 長く使い続ける共有の鍵がありません。** git-crypt はリポジトリを1つの共通鍵で守り、共同作業者全員が最終的にその鍵を持つことになります。アクセスの取り消しもできません。`git-remote-e2ee` は push のたびに新しいランダムな鍵を作り、許可された端末それぞれの鍵で個別に包んで届けます。管理者は履歴を書き換えずに端末を1台だけ取り消せ、取り消された端末にはそれ以降に公開されたものは届きません。
内部では、Git はいつもどおり手元で動きます。ヘルパーが Git から受け取った pack と ref を暗号化し、中身の見えない変更不可のオブジェクトとしてアップロードしてから、中身の見えない `HEAD` ポインタを1つだけアトミックに更新します。現在使える保存先は、**ローカルまたはマウントしたディレクトリ**と、暗号文の運び役として使う**通常の Git リモート**(GitHub、GitLab、bare リポジトリ)です。
@@ -105,7 +105,7 @@ Git を暗号化するツールには2つの系統があります。**ファイ
| push では新しいデータだけをアップロードする | △⁷ | △⁵ | ○ |
| 同時 push で互いの変更が黙って上書きされない | ○ | ×⁸ | ○ |
| 改ざんを検知できる | △⁹ | ○ | ○ |
-| 端末ごとに別の鍵を持つ | △¹⁰ | △¹⁸ | ○ |
+| 共同作業者全員で共有する長期の鍵がない | ×¹⁰ | ○ | ○ |
| メンバーを変えられるのは管理者だけ | × | ×¹¹ | ○ |
| 共同作業者を1人だけ取り消せる | ×¹² | ×¹³ | ○¹⁴ |
| 読み取りと書き込みの権限を分けられる | × | × | △¹⁵ |
@@ -126,7 +126,7 @@ Git を暗号化するツールには2つの系統があります。**ファイ
7. 暗号化したファイルは、変更すると前の版と無関係な暗号文になるため、Git が差分圧縮できません。
8. gcrypt の push は実質的にすべて force push です。明示的な force を求めるオプションは事故を防ぎますが、同時に書き込む人同士の調整はしません。
9. git-crypt は暗号化したファイルの中身を認証しますが、ファイル名、履歴、どのファイルが暗号化されているかは通常の Git データです。
-10. git-crypt の GPG モードは、1つの共通鍵であるリポジトリ鍵を利用者それぞれの GPG 鍵で包みます。GPG を使わない場合は、全員が同じ鍵ファイルを共有します。
+10. git-crypt はリポジトリの存続中ずっと1つの共通鍵を使います。GPG モードではその同じ鍵を利用者それぞれの GPG 鍵で包み、GPG を使わない場合は全員が同じ鍵ファイルを共有します。
11. gcrypt の受信者リストは push する人のローカル設定 `gcrypt.participants` なので、push できる参加者なら誰でも、次の状態を誰が読めるかを決められます。
12. git-crypt の README に、アクセスの取り消しには対応していないと明記されています。
13. `gcrypt.participants` から参加者を外すことはできますが、取り消しの手順は文書化されていません。
@@ -134,7 +134,6 @@ Git を暗号化するツールには2つの系統があります。**ファイ
15. プロトコル上は読み取り・書き込み・管理の権限を分けていますが、現在の CLI で付与できるのは「読み書き」か「読み書きと管理」のどちらかです。
16. 一度同期したことのある clone は、巻き戻しや分岐した履歴を拒否します。まったく新しい clone は、外部のアンカーがないと見分けられません。[セキュリティモデル](#セキュリティモデル)を参照してください。
17. 現在 push できるのは `refs/heads/*` だけです。タグの push とブランチの削除は、何も公開せずに失敗します。
-18. gcrypt は push ごとに新しい pack 鍵で暗号化し、すべての pack 鍵を manifest に列挙して、`gcrypt.participants` のすべての GPG 鍵に向けて暗号化します。この設定がなければ既定の GPG 鍵1つに向けて暗号化するので、複数のマシンは普通1つの秘密鍵を共有します。マシンごとに別の GPG 鍵を並べることもできますが、それを管理・取り消しする手順は gcrypt にはありません。
### 他のツールのほうが向いている場合
diff --git a/README.md b/README.md
index bdb6bcf..aa9e38a 100644
--- a/README.md
+++ b/README.md
@@ -60,10 +60,11 @@ if every authorized key is lost, the data cannot be recovered by anyone.
## Why git-remote-e2ee
-Encrypting a whole Git repository is not new:
-[`git-remote-gcrypt`](https://github.com/spwhitton/git-remote-gcrypt) has done
-it with GnuPG for years. `git-remote-e2ee` keeps that goal and fixes what makes
-an encrypted remote painful to live with:
+Encrypting Git is not new. File-level tools such as
+[`git-crypt`](https://github.com/AGWA/git-crypt) encrypt selected files, and
+[`git-remote-gcrypt`](https://github.com/spwhitton/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 little metadata (3–4 KiB in total for a tiny commit with one device),
@@ -76,13 +77,12 @@ an encrypted remote painful to live with:
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: every device has its own key.** gcrypt encrypts to GPG keys,
- by default to your own, so using it from several machines usually means
- copying one GPG private key to all of them. That one key opens the whole
- history and every future push, and a lost laptop cannot be cut off on its
- own. `git-remote-e2ee` gives each device its own key and needs no GPG. Only
- administrators can change who has access, and revoking a device publishes a
- fresh key without rewriting history.
+- **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-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 published afterwards.
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
@@ -154,7 +154,7 @@ of selected files inside an otherwise normal repository. **Encrypted remotes**,
| Push uploads only new data | △⁷ | △⁵ | ○ |
| Concurrent pushes can't silently overwrite each other | ○ | ×⁸ | ○ |
| Tampering detected | △⁹ | ○ | ○ |
-| A separate key for every device | △¹⁰ | △¹⁸ | ○ |
+| No long-lived key shared by every collaborator | ×¹⁰ | ○ | ○ |
| Only administrators can change membership | × | ×¹¹ | ○ |
| Revoke one collaborator | ×¹² | ×¹³ | ○¹⁴ |
| Separate reader and writer roles | × | × | △¹⁵ |
@@ -190,8 +190,9 @@ of selected files inside an otherwise normal repository. **Encrypted remotes**,
prevents accidents but does not coordinate concurrent writers.
9. git-crypt authenticates encrypted file contents; file names, history, and
which files are encrypted are ordinary Git data.
-10. git-crypt's GPG mode wraps one symmetric repository key to each user's GPG
- key. Without GPG, everyone shares the same exported key file.
+10. 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; without
+ GPG, everyone shares the same exported key file.
11. 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.
@@ -207,11 +208,6 @@ of selected files inside an otherwise normal repository. **Encrypted remotes**,
[Security model](#security-model).
17. Pushes are limited to `refs/heads/*` for now. Tag pushes and branch
deletion fail without publishing anything.
-18. gcrypt encrypts each push with a fresh pack key and lists all pack keys in
- a manifest encrypted to every GPG key in `gcrypt.participants`. Without
- that setting it encrypts to your default GPG key, so several machines
- normally share one private key. Listing a separate GPG key per machine is
- possible, but gcrypt provides no workflow for managing or revoking them.
### When another tool is a better fit
From 575c1ba8b13bfdd1b15ec5c35fbc3b36c214652f Mon Sep 17 00:00:00 2001
From: hmirin
Date: Thu, 1 Oct 2026 08:47:57 +0900
Subject: [PATCH 15/98] Design anonymous recipients for v5
---
docs/design.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 75 insertions(+)
diff --git a/docs/design.md b/docs/design.md
index 2e493ad..a6a1fb1 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -193,6 +193,81 @@ Old manifest headers retain device envelopes. Later compromise of a device
private key can retroactively expose every generation addressed to that device.
There is no forward secrecy for stored history.
+## Anonymous recipients (v5, in progress)
+
+v4 lets storage see each device's per-repository public keys, its roles, and
+every membership change. The reason is structural: envelopes carry a recipient
+device ID so a device can find its own, and verifiers compare that ID set with
+a plaintext policy. v5 removes that exposure so storage learns only a bounded
+reader count, the same class of leak as `gpg --throw-keyids` in
+git-remote-gcrypt.
+
+### What changes
+
+1. **Anonymous, padded envelopes.** The plaintext header carries a list of
+ HPKE envelopes with no recipient ID. The list is padded with dummy
+ envelopes to the next power of two, with a minimum of four. A dummy is a
+ random 32-byte encapsulated key plus random bytes of real ciphertext length,
+ indistinguishable from a real envelope without the recipient private key.
+ The list is sorted by encapsulated-key bytes, so position carries no
+ identity across generations. The HPKE AAD still binds the repository root,
+ format, generation, `C_t`, and the recipient's device ID. The recipient
+ knows its own ID, and the AAD is not transmitted.
+2. **Trial decryption, once per fetch.** A device tries its HPKE key against
+ each envelope of the newest manifest until one opens and the result matches
+ `C_t`. Older generation keys come from the backward link chain as in v4, so
+ only the head generation costs trial decryption: O(padded reader count)
+ X25519 operations per fetch, not per generation. The list length is capped
+ (4096) to bound work on malicious input. If nothing opens, the client
+ reports that this device is not an active reader of the current generation:
+ it was revoked, was never added, or its envelope is malformed.
+3. **Sealed header.** Everything that identified devices moves out of the
+ plaintext header into a sealed header, encrypted under a `K_t`-derived
+ subkey: policy ID and policy generation, signer device ID, transition type,
+ cumulative pack count, the envelope audit (item 5), and the Ed25519
+ signature. The signature covers the plaintext header bytes, the sealed
+ header content without the signature, and the encrypted body digest. The
+ plaintext header keeps only the format version, repository root,
+ generation, previous manifest ID, `C_t`, the envelope list, and the digests
+ needed to locate and authenticate the sealed parts.
+4. **Policy inside the transition manifest.** v5 removes the separate
+ plaintext `policies/` objects. A policy travels inside the encrypted body of
+ the manifest that introduces it: genesis, device add, revoke, or rotate.
+ Later manifests reference it by policy ID inside their sealed header, and
+ readers reach it through the backward key chain. Storage can no longer tell
+ a membership change from a content push, except by size.
+5. **Envelope audit.** Without visible recipient IDs, verifiers cannot compare
+ an envelope ID set with the active-reader set, so a malicious writer could
+ corrupt one reader's envelope unnoticed by everyone else. The sealed header
+ therefore records, for each envelope, either the recipient device ID and the
+ 32-byte seed that fed the HPKE encapsulation RNG, or a dummy marker. After
+ opening its own envelope, every verifier deterministically re-seals `K_t`
+ for each real entry with that seed and the recipient's public key from the
+ policy. It requires byte equality with the stored envelope and exactly one
+ envelope per active reader. Dummy entries only need to be absent from the
+ reader mapping. A seed reveals only the shared secret for that envelope,
+ which yields `K_t`, and every verifier already holds `K_t`. The seed is
+ never reused.
+
+### What storage still learns
+
+The padded envelope count (an upper bound on active readers), object sizes,
+upload timing, generation count, total growth, and the opaque repository root.
+Device public keys, roles, signer identity, and the fact of a membership change
+become encrypted.
+
+### Costs and limits
+
+- Format break: v5 does not read v4 repositories and ships no migration, which
+ is acceptable for a prototype.
+- Padding adds at most 2x envelope metadata per generation.
+- A device that cannot open any envelope cannot tell revocation from
+ corruption. Continuity pins still reject rollback for returning clients.
+- A reader can still see the complete policy after decryption, exactly as in
+ v4.
+- The revocation race window, retroactive exposure on device-key compromise,
+ and the absence of forward secrecy are unchanged.
+
## Cost model
For new encrypted pack bytes `S`, active readers `N`, and a small envelope `G`:
From 40787f7619bb92daf32c4159885fb4c4a857ca92 Mon Sep 17 00:00:00 2001
From: hmirin
Date: Thu, 1 Oct 2026 09:26:45 +0900
Subject: [PATCH 16/98] Implement v5 anonymous recipient envelopes
---
Cargo.lock | 1 +
Cargo.toml | 1 +
src/crypto.rs | 35 +-
src/manifest.rs | 1234 +++++++++++++++++++++++----------
src/policy.rs | 4 +-
src/repository.rs | 316 ++++-----
src/storage.rs | 4 +-
tests/anonymous_recipients.rs | 211 ++++++
tests/end_to_end.rs | 24 +-
tests/remote_helper.rs | 12 +-
10 files changed, 1273 insertions(+), 569 deletions(-)
create mode 100644 tests/anonymous_recipients.rs
diff --git a/Cargo.lock b/Cargo.lock
index b9d9077..bdb2df1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -416,6 +416,7 @@ dependencies = [
"hpke",
"rand 0.8.7",
"rand 0.9.5",
+ "rand_chacha 0.9.0",
"serde",
"serde_json",
"sha2",
diff --git a/Cargo.toml b/Cargo.toml
index 27fc7a4..edac990 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,6 +30,7 @@ hpke = { version = "0.13", default-features = false, features = ["alloc", "x2551
hkdf = "0.12"
rand = "0.8"
rand_09 = { package = "rand", version = "0.9", features = ["os_rng", "std_rng"], default-features = false }
+rand_chacha = "0.9"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"
diff --git a/src/crypto.rs b/src/crypto.rs
index 2988379..d704e43 100644
--- a/src/crypto.rs
+++ b/src/crypto.rs
@@ -17,16 +17,19 @@ use hpke::{Deserializable, Kem, OpModeR, OpModeS, Serializable};
use rand::RngCore;
use rand::rngs::OsRng;
use rand_09::{SeedableRng, rngs::StdRng};
+use rand_chacha::ChaCha20Rng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
-const SYMMETRIC_MAGIC: &[u8; 8] = b"E2EES04\0";
-const PACK_STREAM_MAGIC: &[u8; 8] = b"E2EEPK4\0";
-const HPKE_INFO: &[u8] = b"git-remote-e2ee generation key v4";
-const KEY_COMMITMENT_DOMAIN: &[u8] = b"git-remote-e2ee generation key commitment v4\0";
-const SUBKEY_SALT: &[u8] = b"git-remote-e2ee subkey derivation v4\0";
+const SYMMETRIC_MAGIC: &[u8; 8] = b"E2EES05\0";
+const PACK_STREAM_MAGIC: &[u8; 8] = b"E2EEPK5\0";
+const HPKE_INFO: &[u8] = b"git-remote-e2ee generation key v5";
+const KEY_COMMITMENT_DOMAIN: &[u8] = b"git-remote-e2ee generation key commitment v5\0";
+const SUBKEY_SALT: &[u8] = b"git-remote-e2ee subkey derivation v5\0";
+pub const HPKE_ENCAPSULATED_KEY_SIZE: usize = 32;
+pub const HPKE_CIPHERTEXT_SIZE: usize = 32 + 16;
pub const PACK_STREAM_CHUNK_SIZE: usize = 1024 * 1024;
const PACK_STREAM_NONCE_SIZE: usize = 19;
const PACK_STREAM_TAG_SIZE: usize = 16;
@@ -44,6 +47,7 @@ pub enum SubkeyKind {
ManifestBody = 1,
Pack = 2,
PredecessorLink = 3,
+ SealedHeader = 4,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -236,11 +240,26 @@ pub fn wrap_generation_key(
public_key: &str,
generation_key: &[u8; 32],
aad: &[u8],
+) -> Result<(Vec, Vec)> {
+ wrap_generation_key_with_seed(public_key, generation_key, aad, &random_seed())
+}
+
+pub fn random_seed() -> [u8; 32] {
+ let mut seed = [0_u8; 32];
+ OsRng.fill_bytes(&mut seed);
+ seed
+}
+
+pub fn wrap_generation_key_with_seed(
+ public_key: &str,
+ generation_key: &[u8; 32],
+ aad: &[u8],
+ seed: &[u8; 32],
) -> Result<(Vec, Vec)> {
let public_bytes = BASE64.decode(public_key)?;
let public = ::PublicKey::from_bytes(&public_bytes)
.map_err(|_| anyhow::anyhow!("invalid HPKE public key"))?;
- let mut rng = StdRng::from_os_rng();
+ let mut rng = ChaCha20Rng::from_seed(*seed);
let (encapsulated, ciphertext) = hpke::single_shot_seal::(
&OpModeS::Base,
&public,
@@ -379,7 +398,7 @@ impl Write for DigestWriter {
}
fn pack_stream_aad(base_aad: &[u8], header: &[u8]) -> Vec {
- let mut aad = b"git-remote-e2ee pack stream v4\0".to_vec();
+ let mut aad = b"git-remote-e2ee pack stream v5\0".to_vec();
aad.extend_from_slice(&(base_aad.len() as u32).to_le_bytes());
aad.extend_from_slice(base_aad);
aad.extend_from_slice(&(header.len() as u32).to_le_bytes());
@@ -816,7 +835,7 @@ mod tests {
);
let mut legacy = ciphertext;
- legacy[..PACK_STREAM_MAGIC.len()].copy_from_slice(b"E2EEPK3\0");
+ legacy[..PACK_STREAM_MAGIC.len()].copy_from_slice(b"E2EEPK4\0");
assert!(open_test_pack(&key, &legacy, sealed.plaintext_size, &sealed.object_id).is_err());
}
diff --git a/src/manifest.rs b/src/manifest.rs
index 1692a04..3906530 100644
--- a/src/manifest.rs
+++ b/src/manifest.rs
@@ -3,17 +3,22 @@ use std::collections::{BTreeMap, HashSet};
use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
+use rand::RngCore;
+use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::crypto::{
- KeyFile, SecretKey, SubkeyKind, commit_key, derive_subkey, open_with_key, seal_with_key,
- verify_domain, verify_key_commitment, wrap_generation_key,
+ HPKE_CIPHERTEXT_SIZE, HPKE_ENCAPSULATED_KEY_SIZE, KeyFile, SecretKey, SubkeyKind, commit_key,
+ derive_subkey, open_with_key, random_seed, seal_with_key, verify_domain, verify_key_commitment,
+ wrap_generation_key_with_seed,
};
use crate::policy::PolicyState;
-pub const FORMAT_VERSION: u32 = 4;
-const MANIFEST_SIGNATURE_DOMAIN: &[u8] = b"git-remote-e2ee manifest v4\0";
+pub const FORMAT_VERSION: u32 = 5;
+pub const MAX_GENERATION_ENVELOPES: usize = 4096;
+const MIN_GENERATION_ENVELOPES: usize = 4;
+const MANIFEST_SIGNATURE_DOMAIN: &[u8] = b"git-remote-e2ee manifest v5\0";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -27,11 +32,20 @@ pub struct PackDescriptor {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GenerationKeyEnvelope {
- pub device_id: String,
pub encapsulated_key: String,
pub ciphertext: String,
}
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum EnvelopeAuditEntry {
+ Recipient {
+ device_id: String,
+ encapsulation_seed: String,
+ },
+ Dummy,
+}
+
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Manifest {
pub format_version: u32,
@@ -45,10 +59,11 @@ pub struct Manifest {
pub refs: BTreeMap,
pub new_packs: Vec,
pub predecessor_key_wrap: Option,
+ pub introduced_policy: Option>,
}
impl Manifest {
- pub fn genesis(repository_root: String, policy: &PolicyState) -> Self {
+ pub fn genesis(repository_root: String, policy: &PolicyState, policy_bytes: Vec) -> Self {
Self {
format_version: FORMAT_VERSION,
repository_root,
@@ -61,11 +76,13 @@ impl Manifest {
refs: BTreeMap::new(),
new_packs: Vec::new(),
predecessor_key_wrap: None,
+ introduced_policy: Some(policy_bytes),
}
}
pub fn validate_genesis(&self, policy: &PolicyState) -> Result<()> {
validate_manifest_policy(self, policy)?;
+ validate_introduced_policy(self, policy, true)?;
if self.generation != 0
|| self.previous.is_some()
|| self.authorization != ManifestAuthorization::PolicyTransition
@@ -109,6 +126,7 @@ impl Manifest {
match self.authorization {
ManifestAuthorization::Writer => {
+ validate_introduced_policy(self, selected_policy, false)?;
if self.policy_id != previous.policy_id
|| self.policy_generation != previous.policy_generation
{
@@ -116,6 +134,7 @@ impl Manifest {
}
}
ManifestAuthorization::PolicyTransition => {
+ validate_introduced_policy(self, selected_policy, true)?;
if self.policy_generation != previous.policy_generation + 1
|| selected_policy.body.previous.as_deref() != Some(previous.policy_id.as_str())
{
@@ -141,6 +160,8 @@ pub enum ManifestAuthorization {
Checkpoint,
}
+/// The complete plaintext part of a v5 manifest. Device identities and policy
+/// metadata are only present in the encrypted sealed header.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestHeader {
@@ -148,13 +169,28 @@ pub struct ManifestHeader {
pub repository_root: String,
pub generation: u64,
pub previous: Option,
- pub policy_id: String,
- pub policy_generation: u64,
- pub total_pack_count: u64,
pub key_commitment: String,
pub generation_key_envelopes: Vec,
- pub signer_device_id: String,
- pub authorization: ManifestAuthorization,
+ pub body_digest: String,
+ pub sealed_header_digest: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct SealedHeaderContent {
+ policy_id: String,
+ policy_generation: u64,
+ signer_device_id: String,
+ authorization: ManifestAuthorization,
+ total_pack_count: u64,
+ envelope_audit: Vec,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct SealedHeaderEnvelope {
+ content: String,
+ signature: String,
}
#[derive(Serialize, Deserialize)]
@@ -163,23 +199,36 @@ struct ManifestBody {
refs: BTreeMap,
new_packs: Vec,
predecessor_key_wrap: Option,
+ introduced_policy: Option,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ManifestEnvelope {
header: String,
+ sealed_header_ciphertext: String,
body_ciphertext: String,
- signature: String,
}
-struct ParsedEnvelope {
- header: ManifestHeader,
+#[derive(Clone, Debug)]
+pub struct OpenedManifest {
+ pub(crate) header: ManifestHeader,
+ pub(crate) manifest: Manifest,
exact_header: Vec,
- body_ciphertext: Vec,
+ exact_sealed_content: Vec,
signature: Vec,
}
+impl OpenedManifest {
+ pub fn header(&self) -> &ManifestHeader {
+ &self.header
+ }
+
+ pub fn manifest(&self) -> &Manifest {
+ &self.manifest
+ }
+}
+
pub fn seal_manifest(
key: &KeyFile,
policy: &PolicyState,
@@ -188,153 +237,181 @@ pub fn seal_manifest(
manifest: Manifest,
) -> Result> {
validate_manifest_policy(&manifest, policy)?;
+ if manifest.authorization != authorization {
+ bail!("manifest authorization does not match its transition")
+ }
+ match authorization {
+ ManifestAuthorization::Writer => validate_introduced_policy(&manifest, policy, false)?,
+ ManifestAuthorization::PolicyTransition if manifest.generation == 0 => {
+ validate_introduced_policy(&manifest, policy, true)?
+ }
+ ManifestAuthorization::PolicyTransition => {
+ validate_introduced_policy(&manifest, policy, true)?
+ }
+ ManifestAuthorization::Checkpoint => {
+ bail!("checkpoint transitions are reserved but not implemented")
+ }
+ }
+ validate_pack_delta(&manifest)?;
+
let commitment = commit_key(generation_key);
- let mut envelopes = policy
- .body
- .devices
- .iter()
- .filter(|device| device.active() && device.roles.reader)
- .map(|device| {
- let aad = generation_key_aad(
- &manifest.repository_root,
- manifest.generation,
- &manifest.policy_id,
- &device.public.device_id,
- &commitment,
- );
- let (encapsulated, ciphertext) =
- wrap_generation_key(&device.public.wrapping_public_key, generation_key, &aad)?;
- Ok(GenerationKeyEnvelope {
- device_id: device.public.device_id.clone(),
- encapsulated_key: BASE64.encode(encapsulated),
- ciphertext: BASE64.encode(ciphertext),
- })
- })
- .collect::>>()?;
- envelopes.sort_by(|a, b| a.device_id.cmp(&b.device_id));
+ let (generation_key_envelopes, envelope_audit) =
+ build_envelopes(policy, &manifest, generation_key, &commitment)?;
+ let body = serde_json::to_vec(&ManifestBody {
+ refs: manifest.refs.clone(),
+ new_packs: manifest.new_packs.clone(),
+ predecessor_key_wrap: manifest.predecessor_key_wrap.clone(),
+ introduced_policy: manifest
+ .introduced_policy
+ .as_deref()
+ .map(|bytes| BASE64.encode(bytes)),
+ })?;
+ let body_key = derive_subkey(
+ generation_key,
+ &manifest.repository_root,
+ manifest.generation,
+ SubkeyKind::ManifestBody,
+ 0,
+ )?;
+ let body_ciphertext = seal_with_key(
+ &body_key,
+ &body,
+ &manifest_body_aad(&manifest.repository_root, manifest.generation),
+ )?;
+ let body_digest = hex::encode(Sha256::digest(&body_ciphertext));
+ let content = SealedHeaderContent {
+ policy_id: manifest.policy_id.clone(),
+ policy_generation: manifest.policy_generation,
+ signer_device_id: key.device_id()?,
+ authorization,
+ total_pack_count: manifest.total_pack_count,
+ envelope_audit,
+ };
+ let exact_sealed_content = serde_json::to_vec(&content)?;
let header = ManifestHeader {
format_version: manifest.format_version,
repository_root: manifest.repository_root.clone(),
generation: manifest.generation,
previous: manifest.previous.clone(),
- policy_id: manifest.policy_id.clone(),
- policy_generation: manifest.policy_generation,
- total_pack_count: manifest.total_pack_count,
key_commitment: commitment,
- generation_key_envelopes: envelopes,
- signer_device_id: key.device_id()?,
- authorization,
+ generation_key_envelopes,
+ body_digest,
+ sealed_header_digest: hex::encode(Sha256::digest(&exact_sealed_content)),
};
- validate_recipient_set(&header, policy)?;
+ validate_public_header(&header)?;
+ validate_envelope_layout(&header)?;
let exact_header = serde_json::to_vec(&header)?;
- let body = serde_json::to_vec(&ManifestBody {
- refs: manifest.refs,
- new_packs: manifest.new_packs,
- predecessor_key_wrap: manifest.predecessor_key_wrap,
- })?;
- let body_key = derive_subkey(
+ let signed_bytes =
+ manifest_signed_bytes(&exact_header, &exact_sealed_content, &header.body_digest);
+ let sealed = SealedHeaderEnvelope {
+ content: BASE64.encode(&exact_sealed_content),
+ signature: BASE64.encode(key.sign_domain(MANIFEST_SIGNATURE_DOMAIN, &signed_bytes)?),
+ };
+ let sealed_bytes = serde_json::to_vec(&sealed)?;
+ let sealed_key = derive_subkey(
generation_key,
&header.repository_root,
header.generation,
- SubkeyKind::ManifestBody,
+ SubkeyKind::SealedHeader,
0,
)?;
- let body_ciphertext = seal_with_key(
- &body_key,
- &body,
- &manifest_body_aad(&header.repository_root, header.generation),
- )?;
- let signed = manifest_signed_bytes(&exact_header, &body_ciphertext);
- let envelope = ManifestEnvelope {
+ let sealed_header_ciphertext = seal_with_key(&sealed_key, &sealed_bytes, &exact_header)?;
+ Ok(serde_json::to_vec(&ManifestEnvelope {
header: BASE64.encode(exact_header),
+ sealed_header_ciphertext: BASE64.encode(sealed_header_ciphertext),
body_ciphertext: BASE64.encode(body_ciphertext),
- signature: BASE64.encode(key.sign_domain(MANIFEST_SIGNATURE_DOMAIN, &signed)?),
- };
- Ok(serde_json::to_vec(&envelope)?)
-}
-
-pub fn read_manifest_header(
- encrypted: &[u8],
- policy: &PolicyState,
- parent_policy: Option<&PolicyState>,
-) -> Result {
- let parsed = parse_envelope(encrypted)?;
- let header = parsed.header;
- if header.format_version != FORMAT_VERSION {
- bail!("unsupported manifest format {}", header.format_version)
- }
- if header.repository_root != policy.body.repository_root
- || header.policy_id != policy.id
- || header.policy_generation != policy.body.generation
- {
- bail!("manifest header does not match its policy")
- }
- validate_recipient_set(&header, policy)?;
- let signer = match header.authorization {
- ManifestAuthorization::Writer => policy
- .device(&header.signer_device_id)
- .filter(|device| device.active() && device.roles.writer)
- .context("manifest was not signed by an active writer")?,
- ManifestAuthorization::PolicyTransition => {
- let authorizing = parent_policy.unwrap_or(policy);
- authorizing
- .device(&header.signer_device_id)
- .filter(|device| device.active() && device.roles.administrator)
- .context("policy transition manifest was not signed by a parent administrator")?
- }
- ManifestAuthorization::Checkpoint => {
- bail!("checkpoint transitions are reserved but not implemented")
- }
- };
- verify_domain(
- &signer.public.signing_public_key,
- MANIFEST_SIGNATURE_DOMAIN,
- &manifest_signed_bytes(&parsed.exact_header, &parsed.body_ciphertext),
- &parsed.signature,
- )?;
- Ok(header)
+ })?)
}
pub fn peek_manifest_header(encrypted: &[u8]) -> Result {
- Ok(parse_envelope(encrypted)?.header)
+ let envelope: ManifestEnvelope =
+ serde_json::from_slice(encrypted).context("parse manifest envelope")?;
+ let exact_header = BASE64
+ .decode(envelope.header)
+ .context("decode manifest header")?;
+ let header: ManifestHeader =
+ serde_json::from_slice(&exact_header).context("parse manifest header")?;
+ validate_public_header(&header)?;
+ Ok(header)
}
pub fn unwrap_generation_key(header: &ManifestHeader, key: &KeyFile) -> Result {
if key.repository_root != header.repository_root {
bail!("key file belongs to a different repository")
}
+ validate_public_header(header)?;
let device_id = key.device_id()?;
- let envelope = header
- .generation_key_envelopes
- .iter()
- .find(|envelope| envelope.device_id == device_id)
- .context("device is not an active reader in this generation")?;
- let aad = generation_key_aad(
+ for envelope in &header.generation_key_envelopes {
+ let Ok(encapsulated_key) = BASE64.decode(&envelope.encapsulated_key) else {
+ continue;
+ };
+ let Ok(ciphertext) = BASE64.decode(&envelope.ciphertext) else {
+ continue;
+ };
+ let aad = generation_key_aad(
+ &header.repository_root,
+ header.generation,
+ &device_id,
+ &header.key_commitment,
+ );
+ if let Ok(generation_key) = key.unwrap_generation_key(&encapsulated_key, &ciphertext, &aad)
+ && verify_key_commitment(&generation_key, &header.key_commitment).is_ok()
+ {
+ return Ok(generation_key);
+ }
+ }
+ bail!(
+ "device is not an active reader of the current generation (revoked, never added, or envelope malformed)"
+ )
+}
+
+pub fn open_manifest(encrypted: &[u8], generation_key: &[u8; 32]) -> Result {
+ let envelope: ManifestEnvelope =
+ serde_json::from_slice(encrypted).context("parse manifest envelope")?;
+ let exact_header = BASE64
+ .decode(envelope.header)
+ .context("decode manifest header")?;
+ let header: ManifestHeader =
+ serde_json::from_slice(&exact_header).context("parse manifest header")?;
+ validate_public_header(&header)?;
+ verify_key_commitment(generation_key, &header.key_commitment)?;
+ let sealed_header_ciphertext = BASE64
+ .decode(envelope.sealed_header_ciphertext)
+ .context("decode sealed manifest header")?;
+ let body_ciphertext = BASE64
+ .decode(envelope.body_ciphertext)
+ .context("decode manifest body")?;
+ if hex::encode(Sha256::digest(&body_ciphertext)) != header.body_digest {
+ bail!("manifest body digest mismatch")
+ }
+
+ let sealed_key = derive_subkey(
+ generation_key,
&header.repository_root,
header.generation,
- &header.policy_id,
- &device_id,
- &header.key_commitment,
- );
- let generation_key = key.unwrap_generation_key(
- &BASE64.decode(&envelope.encapsulated_key)?,
- &BASE64.decode(&envelope.ciphertext)?,
- &aad,
+ SubkeyKind::SealedHeader,
+ 0,
)?;
- verify_key_commitment(&generation_key, &header.key_commitment)?;
- Ok(generation_key)
-}
+ let sealed_bytes = open_with_key(&sealed_key, &sealed_header_ciphertext, &exact_header)
+ .context("open sealed manifest header")?;
+ let sealed: SealedHeaderEnvelope =
+ serde_json::from_slice(&sealed_bytes).context("parse sealed manifest header")?;
+ let exact_sealed_content = BASE64
+ .decode(sealed.content)
+ .context("decode sealed manifest content")?;
+ if hex::encode(Sha256::digest(&exact_sealed_content)) != header.sealed_header_digest {
+ bail!("sealed manifest header digest mismatch")
+ }
+ let content: SealedHeaderContent =
+ serde_json::from_slice(&exact_sealed_content).context("parse sealed manifest content")?;
+ if serde_json::to_vec(&content)? != exact_sealed_content {
+ bail!("sealed manifest content is not canonically encoded")
+ }
+ let signature = BASE64
+ .decode(sealed.signature)
+ .context("decode manifest signature")?;
-pub fn open_manifest(
- encrypted: &[u8],
- policy: &PolicyState,
- parent_policy: Option<&PolicyState>,
- generation_key: &[u8; 32],
-) -> Result {
- let header = read_manifest_header(encrypted, policy, parent_policy)?;
- verify_key_commitment(generation_key, &header.key_commitment)?;
- let parsed = parse_envelope(encrypted)?;
let body_key = derive_subkey(
generation_key,
&header.repository_root,
@@ -344,28 +421,111 @@ pub fn open_manifest(
)?;
let body_bytes = open_with_key(
&body_key,
- &parsed.body_ciphertext,
+ &body_ciphertext,
&manifest_body_aad(&header.repository_root, header.generation),
)?;
let body: ManifestBody = serde_json::from_slice(&body_bytes).context("parse manifest body")?;
+ let introduced_policy = body
+ .introduced_policy
+ .map(|encoded| BASE64.decode(encoded).context("decode introduced policy"))
+ .transpose()?;
let manifest = Manifest {
format_version: header.format_version,
- repository_root: header.repository_root,
+ repository_root: header.repository_root.clone(),
generation: header.generation,
- previous: header.previous,
- policy_id: header.policy_id,
- policy_generation: header.policy_generation,
- authorization: header.authorization,
- total_pack_count: header.total_pack_count,
+ previous: header.previous.clone(),
+ policy_id: content.policy_id,
+ policy_generation: content.policy_generation,
+ authorization: content.authorization,
+ total_pack_count: content.total_pack_count,
refs: body.refs,
new_packs: body.new_packs,
predecessor_key_wrap: body.predecessor_key_wrap,
+ introduced_policy,
};
validate_pack_delta(&manifest)?;
if (manifest.generation == 0) != manifest.predecessor_key_wrap.is_none() {
bail!("manifest predecessor key link does not match its generation")
}
- Ok(manifest)
+ if manifest.generation == 0 && manifest.previous.is_some()
+ || manifest.generation > 0 && manifest.previous.is_none()
+ {
+ bail!("manifest previous pointer does not match its generation")
+ }
+ Ok(OpenedManifest {
+ header,
+ manifest,
+ exact_header,
+ exact_sealed_content,
+ signature,
+ })
+}
+
+pub fn verify_manifest(
+ opened: &OpenedManifest,
+ selected_policy: &PolicyState,
+ parent_policy: Option<&PolicyState>,
+ generation_key: &[u8; 32],
+) -> Result<()> {
+ let manifest = &opened.manifest;
+ if manifest.format_version != FORMAT_VERSION
+ || manifest.repository_root != selected_policy.body.repository_root
+ || manifest.policy_id != selected_policy.id
+ || manifest.policy_generation != selected_policy.body.generation
+ {
+ bail!("manifest does not match policy state")
+ }
+ validate_public_header(&opened.header)?;
+ validate_envelope_layout(&opened.header)?;
+ if manifest.format_version != opened.header.format_version
+ || manifest.repository_root != opened.header.repository_root
+ || manifest.generation != opened.header.generation
+ || manifest.previous != opened.header.previous
+ {
+ bail!("sealed manifest metadata does not match its plaintext header")
+ }
+ let content: SealedHeaderContent = serde_json::from_slice(&opened.exact_sealed_content)
+ .context("parse sealed manifest content")?;
+ if serde_json::to_vec(&content)? != opened.exact_sealed_content
+ || content.policy_id != manifest.policy_id
+ || content.policy_generation != manifest.policy_generation
+ || content.authorization != manifest.authorization
+ || content.total_pack_count != manifest.total_pack_count
+ {
+ bail!("opened manifest fields do not match the signed sealed header")
+ }
+ let authorizing = match content.authorization {
+ ManifestAuthorization::Writer => selected_policy
+ .device(&content.signer_device_id)
+ .filter(|device| device.active() && device.roles.writer)
+ .context("manifest was not signed by an active writer")?,
+ ManifestAuthorization::PolicyTransition => {
+ let authorizing = parent_policy.unwrap_or(selected_policy);
+ authorizing
+ .device(&content.signer_device_id)
+ .filter(|device| device.active() && device.roles.administrator)
+ .context("policy transition manifest was not signed by a parent administrator")?
+ }
+ ManifestAuthorization::Checkpoint => {
+ bail!("checkpoint transitions are reserved but not implemented")
+ }
+ };
+ verify_domain(
+ &authorizing.public.signing_public_key,
+ MANIFEST_SIGNATURE_DOMAIN,
+ &manifest_signed_bytes(
+ &opened.exact_header,
+ &opened.exact_sealed_content,
+ &opened.header.body_digest,
+ ),
+ &opened.signature,
+ )?;
+ validate_envelope_audit(
+ &opened.header,
+ &content.envelope_audit,
+ selected_policy,
+ generation_key,
+ )
}
pub fn wrap_predecessor_key(
@@ -429,27 +589,148 @@ pub fn unwrap_predecessor_key(
}
pub fn pack_aad(repository_root: &str, generation: u64, ordinal: u64) -> Vec {
- let mut aad = b"git-remote-e2ee pack v4\0".to_vec();
+ let mut aad = b"git-remote-e2ee pack v5\0".to_vec();
push_field(&mut aad, repository_root.as_bytes());
aad.extend_from_slice(&generation.to_le_bytes());
aad.extend_from_slice(&ordinal.to_le_bytes());
aad
}
-fn parse_envelope(encrypted: &[u8]) -> Result {
- let envelope: ManifestEnvelope =
- serde_json::from_slice(encrypted).context("parse manifest envelope")?;
- let exact_header = BASE64
- .decode(envelope.header)
- .context("decode manifest header")?;
- let header: ManifestHeader =
- serde_json::from_slice(&exact_header).context("parse manifest header")?;
- Ok(ParsedEnvelope {
- header,
- exact_header,
- body_ciphertext: BASE64.decode(envelope.body_ciphertext)?,
- signature: BASE64.decode(envelope.signature)?,
- })
+fn build_envelopes(
+ policy: &PolicyState,
+ manifest: &Manifest,
+ generation_key: &[u8; 32],
+ commitment: &str,
+) -> Result<(Vec, Vec)> {
+ let readers: Vec<_> = policy
+ .body
+ .devices
+ .iter()
+ .filter(|device| device.active() && device.roles.reader)
+ .collect();
+ if readers.is_empty() {
+ bail!("policy has no active readers")
+ }
+ let count = readers
+ .len()
+ .checked_next_power_of_two()
+ .context("active reader count is too large")?
+ .max(MIN_GENERATION_ENVELOPES);
+ if count > MAX_GENERATION_ENVELOPES {
+ bail!("padded generation envelope count exceeds {MAX_GENERATION_ENVELOPES}")
+ }
+
+ let mut pairs = Vec::with_capacity(count);
+ for device in readers {
+ let seed = random_seed();
+ let aad = generation_key_aad(
+ &manifest.repository_root,
+ manifest.generation,
+ &device.public.device_id,
+ commitment,
+ );
+ let (encapsulated, ciphertext) = wrap_generation_key_with_seed(
+ &device.public.wrapping_public_key,
+ generation_key,
+ &aad,
+ &seed,
+ )?;
+ pairs.push((
+ encapsulated.clone(),
+ GenerationKeyEnvelope {
+ encapsulated_key: BASE64.encode(&encapsulated),
+ ciphertext: BASE64.encode(ciphertext),
+ },
+ EnvelopeAuditEntry::Recipient {
+ device_id: device.public.device_id.clone(),
+ encapsulation_seed: BASE64.encode(seed),
+ },
+ ));
+ }
+ while pairs.len() < count {
+ let encapsulated = random_seed().to_vec();
+ let mut ciphertext = vec![0_u8; HPKE_CIPHERTEXT_SIZE];
+ OsRng.fill_bytes(&mut ciphertext);
+ pairs.push((
+ encapsulated.clone(),
+ GenerationKeyEnvelope {
+ encapsulated_key: BASE64.encode(encapsulated),
+ ciphertext: BASE64.encode(ciphertext),
+ },
+ EnvelopeAuditEntry::Dummy,
+ ));
+ }
+ pairs.sort_by(|left, right| left.0.cmp(&right.0));
+ let mut envelopes = Vec::with_capacity(count);
+ let mut audit = Vec::with_capacity(count);
+ for (_, envelope, entry) in pairs {
+ envelopes.push(envelope);
+ audit.push(entry);
+ }
+ Ok((envelopes, audit))
+}
+
+fn validate_envelope_audit(
+ header: &ManifestHeader,
+ audit: &[EnvelopeAuditEntry],
+ policy: &PolicyState,
+ generation_key: &[u8; 32],
+) -> Result<()> {
+ if audit.len() != header.generation_key_envelopes.len() {
+ bail!("envelope audit count does not match the envelope list")
+ }
+ let active: HashSet<_> = policy
+ .body
+ .devices
+ .iter()
+ .filter(|device| device.active() && device.roles.reader)
+ .map(|device| device.public.device_id.as_str())
+ .collect();
+ let mut audited = HashSet::with_capacity(audit.len());
+ for (envelope, entry) in header.generation_key_envelopes.iter().zip(audit) {
+ let EnvelopeAuditEntry::Recipient {
+ device_id,
+ encapsulation_seed,
+ } = entry
+ else {
+ continue;
+ };
+ if !active.contains(device_id.as_str()) {
+ bail!("envelope audit names a device that is not an active reader")
+ }
+ if !audited.insert(device_id.as_str()) {
+ bail!("envelope audit contains a duplicate reader")
+ }
+ let seed: [u8; 32] = BASE64
+ .decode(encapsulation_seed)
+ .context("decode envelope audit seed")?
+ .try_into()
+ .map_err(|_| anyhow::anyhow!("invalid envelope audit seed length"))?;
+ let recipient = policy
+ .device(device_id)
+ .context("envelope audit names an unknown device")?;
+ let aad = generation_key_aad(
+ &header.repository_root,
+ header.generation,
+ device_id,
+ &header.key_commitment,
+ );
+ let (encapsulated, ciphertext) = wrap_generation_key_with_seed(
+ &recipient.public.wrapping_public_key,
+ generation_key,
+ &aad,
+ &seed,
+ )?;
+ if BASE64.encode(encapsulated) != envelope.encapsulated_key
+ || BASE64.encode(ciphertext) != envelope.ciphertext
+ {
+ bail!("generation envelope does not match its sealed audit entry")
+ }
+ }
+ if audited != active {
+ bail!("envelope audit must match active readers exactly")
+ }
+ Ok(())
}
fn validate_manifest_policy(manifest: &Manifest, policy: &PolicyState) -> Result<()> {
@@ -463,21 +744,88 @@ fn validate_manifest_policy(manifest: &Manifest, policy: &PolicyState) -> Result
Ok(())
}
-fn validate_recipient_set(header: &ManifestHeader, policy: &PolicyState) -> Result<()> {
- let active: HashSet<_> = policy
- .body
- .devices
- .iter()
- .filter(|device| device.active() && device.roles.reader)
- .map(|device| device.public.device_id.as_str())
- .collect();
- let wrapped: HashSet<_> = header
- .generation_key_envelopes
- .iter()
- .map(|envelope| envelope.device_id.as_str())
- .collect();
- if wrapped.len() != header.generation_key_envelopes.len() || wrapped != active {
- bail!("generation key envelopes must match active readers exactly")
+fn validate_introduced_policy(
+ manifest: &Manifest,
+ selected_policy: &PolicyState,
+ should_introduce: bool,
+) -> Result<()> {
+ match (&manifest.introduced_policy, should_introduce) {
+ (Some(bytes), true) => {
+ let policy = PolicyState::parse(bytes).context("parse introduced policy")?;
+ if policy.id != selected_policy.id {
+ bail!("manifest introduced policy id does not match its sealed header")
+ }
+ }
+ (None, false) => {}
+ (Some(_), false) => bail!("ordinary manifest unexpectedly introduces a policy"),
+ (None, true) => bail!("policy transition manifest is missing its introduced policy"),
+ }
+ Ok(())
+}
+
+fn validate_public_header(header: &ManifestHeader) -> Result<()> {
+ if header.format_version != FORMAT_VERSION {
+ bail!("unsupported manifest format {}", header.format_version)
+ }
+ if header.repository_root.len() != 64
+ || !header
+ .repository_root
+ .bytes()
+ .all(|byte| byte.is_ascii_hexdigit())
+ {
+ bail!("invalid manifest repository root")
+ }
+ if header.key_commitment.len() != 64
+ || !header
+ .key_commitment
+ .bytes()
+ .all(|byte| byte.is_ascii_hexdigit())
+ {
+ bail!("invalid generation key commitment")
+ }
+ if header.body_digest.len() != 64
+ || !header
+ .body_digest
+ .bytes()
+ .all(|byte| byte.is_ascii_hexdigit())
+ || header.sealed_header_digest.len() != 64
+ || !header
+ .sealed_header_digest
+ .bytes()
+ .all(|byte| byte.is_ascii_hexdigit())
+ {
+ bail!("invalid manifest part digest")
+ }
+ let count = header.generation_key_envelopes.len();
+ if !(MIN_GENERATION_ENVELOPES..=MAX_GENERATION_ENVELOPES).contains(&count)
+ || !count.is_power_of_two()
+ {
+ bail!("invalid padded generation envelope count")
+ }
+ Ok(())
+}
+
+fn validate_envelope_layout(header: &ManifestHeader) -> Result<()> {
+ let mut previous: Option> = None;
+ for envelope in &header.generation_key_envelopes {
+ let encapsulated = BASE64
+ .decode(&envelope.encapsulated_key)
+ .context("decode HPKE encapsulated key")?;
+ let ciphertext = BASE64
+ .decode(&envelope.ciphertext)
+ .context("decode HPKE ciphertext")?;
+ if encapsulated.len() != HPKE_ENCAPSULATED_KEY_SIZE
+ || ciphertext.len() != HPKE_CIPHERTEXT_SIZE
+ {
+ bail!("invalid generation envelope length")
+ }
+ if previous
+ .as_ref()
+ .is_some_and(|prior| prior >= &encapsulated)
+ {
+ bail!("generation envelopes are not sorted by encapsulated-key bytes")
+ }
+ previous = Some(encapsulated);
}
Ok(())
}
@@ -498,38 +846,38 @@ fn validate_pack_delta(manifest: &Manifest) -> Result<()> {
Ok(())
}
-fn manifest_signed_bytes(exact_header: &[u8], body_ciphertext: &[u8]) -> Vec {
- let mut bytes = Vec::with_capacity(exact_header.len() + 32);
- bytes.extend_from_slice(exact_header);
- bytes.extend_from_slice(&Sha256::digest(body_ciphertext));
+fn manifest_signed_bytes(exact_header: &[u8], exact_content: &[u8], body_digest: &str) -> Vec {
+ let mut bytes = Vec::with_capacity(exact_header.len() + exact_content.len() + 72);
+ push_field(&mut bytes, exact_header);
+ push_field(&mut bytes, exact_content);
+ push_field(&mut bytes, body_digest.as_bytes());
bytes
}
fn generation_key_aad(
root: &str,
generation: u64,
- policy_id: &str,
recipient_id: &str,
commitment: &str,
) -> Vec {
- let mut aad = b"git-remote-e2ee generation envelope v4\0".to_vec();
+ let mut aad = b"git-remote-e2ee generation envelope v5\0".to_vec();
push_field(&mut aad, root.as_bytes());
+ aad.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
aad.extend_from_slice(&generation.to_le_bytes());
- push_field(&mut aad, policy_id.as_bytes());
- push_field(&mut aad, recipient_id.as_bytes());
push_field(&mut aad, commitment.as_bytes());
+ push_field(&mut aad, recipient_id.as_bytes());
aad
}
fn manifest_body_aad(root: &str, generation: u64) -> Vec {
- let mut aad = b"git-remote-e2ee manifest body v4\0".to_vec();
+ let mut aad = b"git-remote-e2ee manifest body v5\0".to_vec();
push_field(&mut aad, root.as_bytes());
aad.extend_from_slice(&generation.to_le_bytes());
aad
}
fn predecessor_link_aad(root: &str, generation: u64, previous_id: &str) -> Vec {
- let mut aad = b"git-remote-e2ee predecessor link v4\0".to_vec();
+ let mut aad = b"git-remote-e2ee predecessor link v5\0".to_vec();
push_field(&mut aad, root.as_bytes());
aad.extend_from_slice(&generation.to_le_bytes());
push_field(&mut aad, previous_id.as_bytes());
@@ -544,7 +892,7 @@ fn push_field(output: &mut Vec, value: &[u8]) {
#[cfg(test)]
mod tests {
use super::*;
- use crate::crypto::{PublicDevice, random_key};
+ use crate::crypto::{KeyFile, open_with_key, random_key};
use crate::policy::{DeviceRecord, DeviceRoles};
fn add_reader(owner: &KeyFile, parent: &PolicyState, reader: &KeyFile) -> PolicyState {
@@ -559,12 +907,156 @@ mod tests {
policy
}
+ fn manifest_for(
+ policy: &PolicyState,
+ generation: u64,
+ auth: ManifestAuthorization,
+ ) -> Manifest {
+ Manifest {
+ format_version: FORMAT_VERSION,
+ repository_root: policy.body.repository_root.clone(),
+ generation,
+ previous: (generation > 0).then(|| "11".repeat(32)),
+ policy_id: policy.id.clone(),
+ policy_generation: policy.body.generation,
+ authorization: auth,
+ total_pack_count: 0,
+ refs: BTreeMap::new(),
+ new_packs: Vec::new(),
+ predecessor_key_wrap: (generation > 0).then(|| BASE64.encode([7_u8; 64])),
+ introduced_policy: None,
+ }
+ }
+
+ fn rewrite_signed_manifest(
+ encrypted: &[u8],
+ signer: &KeyFile,
+ generation_key: &[u8; 32],
+ edit: impl FnOnce(&mut ManifestHeader, &mut SealedHeaderContent),
+ ) -> Vec {
+ let mut envelope: ManifestEnvelope = serde_json::from_slice(encrypted).unwrap();
+ let mut header_bytes = BASE64.decode(&envelope.header).unwrap();
+ let mut header: ManifestHeader = serde_json::from_slice(&header_bytes).unwrap();
+ let sealed_ciphertext = BASE64.decode(&envelope.sealed_header_ciphertext).unwrap();
+ let sealed_key = derive_subkey(
+ generation_key,
+ &header.repository_root,
+ header.generation,
+ SubkeyKind::SealedHeader,
+ 0,
+ )
+ .unwrap();
+ let sealed_bytes = open_with_key(&sealed_key, &sealed_ciphertext, &header_bytes).unwrap();
+ let mut sealed: SealedHeaderEnvelope = serde_json::from_slice(&sealed_bytes).unwrap();
+ let mut content: SealedHeaderContent =
+ serde_json::from_slice(&BASE64.decode(&sealed.content).unwrap()).unwrap();
+ edit(&mut header, &mut content);
+ let exact_content = serde_json::to_vec(&content).unwrap();
+ header.sealed_header_digest = hex::encode(Sha256::digest(&exact_content));
+ header_bytes = serde_json::to_vec(&header).unwrap();
+ let body_ciphertext = BASE64.decode(&envelope.body_ciphertext).unwrap();
+ let signed = manifest_signed_bytes(&header_bytes, &exact_content, &header.body_digest);
+ sealed.content = BASE64.encode(&exact_content);
+ sealed.signature = BASE64.encode(
+ signer
+ .sign_domain(MANIFEST_SIGNATURE_DOMAIN, &signed)
+ .unwrap(),
+ );
+ let sealed_bytes = serde_json::to_vec(&sealed).unwrap();
+ envelope.header = BASE64.encode(&header_bytes);
+ envelope.sealed_header_ciphertext =
+ BASE64.encode(seal_with_key(&sealed_key, &sealed_bytes, &header_bytes).unwrap());
+ envelope.body_ciphertext = BASE64.encode(body_ciphertext);
+ serde_json::to_vec(&envelope).unwrap()
+ }
+
+ fn writer_manifest(
+ signer: &KeyFile,
+ policy: &PolicyState,
+ generation_key: &[u8; 32],
+ ) -> Vec {
+ let manifest = manifest_for(policy, 2, ManifestAuthorization::Writer);
+ seal_manifest(
+ signer,
+ policy,
+ generation_key,
+ ManifestAuthorization::Writer,
+ manifest,
+ )
+ .unwrap()
+ }
+
+ #[test]
+ fn anonymous_envelopes_are_padded_and_open_by_trial_decryption() {
+ let owner = KeyFile::generate();
+ let reader_a = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap();
+ let reader_b = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap();
+ let (genesis, genesis_bytes) = PolicyState::genesis(&owner).unwrap();
+ let mut devices = genesis.body.devices.clone();
+ for reader in [&reader_a, &reader_b] {
+ devices.push(DeviceRecord {
+ public: reader.public_device().unwrap(),
+ roles: DeviceRoles::collaborator(),
+ revoked_at: None,
+ });
+ }
+ let (policy, _) = PolicyState::successor(&genesis, devices, &owner).unwrap();
+ let (policy5, _policy5_bytes) =
+ PolicyState::successor(&policy, policy.body.devices.clone(), &owner).unwrap();
+ let mut one_reader = manifest_for(&genesis, 0, ManifestAuthorization::PolicyTransition);
+ one_reader.introduced_policy = Some(genesis_bytes);
+ let bytes = seal_manifest(
+ &owner,
+ &genesis,
+ &random_key(),
+ ManifestAuthorization::PolicyTransition,
+ one_reader,
+ )
+ .unwrap();
+ let header = peek_manifest_header(&bytes).unwrap();
+ assert_eq!(header.generation_key_envelopes.len(), 4);
+ let key = unwrap_generation_key(&header, &owner).unwrap();
+ let opened = open_manifest(&bytes, &key).unwrap();
+ verify_manifest(&opened, &genesis, None, &key).unwrap();
+
+ let mut five_readers_devices = policy5.body.devices.clone();
+ for _ in 0..2 {
+ let extra = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap();
+ five_readers_devices.push(DeviceRecord {
+ public: extra.public_device().unwrap(),
+ roles: DeviceRoles::collaborator(),
+ revoked_at: None,
+ });
+ }
+ let (five_readers, five_readers_bytes) =
+ PolicyState::successor(&policy5, five_readers_devices, &owner).unwrap();
+ let mut many = manifest_for(&five_readers, 4, ManifestAuthorization::PolicyTransition);
+ many.previous = Some("22".repeat(32));
+ many.predecessor_key_wrap = Some(BASE64.encode([8_u8; 64]));
+ many.introduced_policy = Some(five_readers_bytes);
+ let many_bytes = seal_manifest(
+ &owner,
+ &five_readers,
+ &random_key(),
+ ManifestAuthorization::PolicyTransition,
+ many,
+ )
+ .unwrap();
+ assert_eq!(
+ peek_manifest_header(&many_bytes)
+ .unwrap()
+ .generation_key_envelopes
+ .len(),
+ 8
+ );
+ }
+
#[test]
fn predecessor_link_must_match_parent_commitment() {
let owner = KeyFile::generate();
- let (policy, _) = PolicyState::genesis(&owner).unwrap();
+ let (policy, policy_bytes) = PolicyState::genesis(&owner).unwrap();
let parent_key = random_key();
- let parent = Manifest::genesis(owner.repository_root.clone(), &policy);
+ let parent = Manifest::genesis(owner.repository_root.clone(), &policy, policy_bytes);
let parent_bytes = seal_manifest(
&owner,
&policy,
@@ -574,32 +1066,22 @@ mod tests {
)
.unwrap();
let parent_id = crate::crypto::object_id(&parent_bytes);
- let parent_header = read_manifest_header(&parent_bytes, &policy, None).unwrap();
+ let parent_header = peek_manifest_header(&parent_bytes).unwrap();
let child_key = random_key();
let wrong_parent_key = random_key();
- let child = Manifest {
- format_version: FORMAT_VERSION,
- repository_root: owner.repository_root.clone(),
- generation: 1,
- previous: Some(parent_id.clone()),
- policy_id: policy.id.clone(),
- policy_generation: policy.body.generation,
- authorization: ManifestAuthorization::Writer,
- total_pack_count: 0,
- refs: BTreeMap::new(),
- new_packs: Vec::new(),
- predecessor_key_wrap: Some(
- wrap_predecessor_key(
- &child_key,
- &wrong_parent_key,
- &owner.repository_root,
- 1,
- &parent_id,
- )
- .unwrap(),
- ),
- };
+ let mut child = manifest_for(&policy, 1, ManifestAuthorization::Writer);
+ child.previous = Some(parent_id.clone());
+ child.predecessor_key_wrap = Some(
+ wrap_predecessor_key(
+ &child_key,
+ &wrong_parent_key,
+ &owner.repository_root,
+ 1,
+ &parent_id,
+ )
+ .unwrap(),
+ );
let child_bytes = seal_manifest(
&owner,
&policy,
@@ -608,186 +1090,220 @@ mod tests {
child,
)
.unwrap();
- let opened = open_manifest(&child_bytes, &policy, None, &child_key).unwrap();
- assert!(unwrap_predecessor_key(&child_key, &opened, &parent_header).is_err());
+ let opened = open_manifest(&child_bytes, &child_key).unwrap();
+ assert!(unwrap_predecessor_key(&child_key, &opened.manifest, &parent_header).is_err());
}
#[test]
- fn every_validator_rejects_missing_or_duplicate_recipient_envelopes() {
+ fn malicious_writer_cannot_replace_a_reader_envelope_with_garbage() {
let owner = KeyFile::generate();
let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap();
- let (parent, _) = PolicyState::genesis(&owner).unwrap();
- let policy = add_reader(&owner, &parent, &reader);
+ let (genesis, _) = PolicyState::genesis(&owner).unwrap();
+ let policy = add_reader(&owner, &genesis, &reader);
let generation_key = random_key();
- let manifest = Manifest {
- format_version: FORMAT_VERSION,
- repository_root: owner.repository_root.clone(),
- generation: 1,
- previous: Some("00".repeat(32)),
- policy_id: policy.id.clone(),
- policy_generation: policy.body.generation,
- authorization: ManifestAuthorization::PolicyTransition,
- total_pack_count: 0,
- refs: BTreeMap::new(),
- new_packs: Vec::new(),
- predecessor_key_wrap: Some(BASE64.encode([0_u8; 64])),
- };
- let bytes = seal_manifest(
+ let valid = writer_manifest(&reader, &policy, &generation_key);
+ let corrupted = rewrite_signed_manifest(
+ &valid,
+ &reader,
+ &generation_key,
+ |header, audit| {
+ let index = audit
+ .envelope_audit
+ .iter()
+ .position(|entry| matches!(entry, EnvelopeAuditEntry::Recipient { device_id, .. } if device_id == &reader.device_id().unwrap()))
+ .unwrap();
+ header.generation_key_envelopes[index].ciphertext =
+ BASE64.encode([0_u8; HPKE_CIPHERTEXT_SIZE]);
+ },
+ );
+ let header = peek_manifest_header(&corrupted).unwrap();
+ let owner_key = unwrap_generation_key(&header, &owner).unwrap();
+ assert_eq!(*owner_key, *generation_key);
+ let opened = open_manifest(&corrupted, &owner_key).unwrap();
+ let error = verify_manifest(&opened, &policy, None, &owner_key)
+ .unwrap_err()
+ .to_string();
+ assert!(error.contains("does not match its sealed audit entry"));
+ }
+
+ #[test]
+ fn reader_trials_all_envelopes_and_reports_inactive_membership_clearly() {
+ let owner = KeyFile::generate();
+ let outsider = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap();
+ let (policy, bytes) = PolicyState::genesis(&owner).unwrap();
+ let manifest = Manifest::genesis(owner.repository_root.clone(), &policy, bytes);
+ let encrypted = seal_manifest(
&owner,
&policy,
- &generation_key,
+ &random_key(),
ManifestAuthorization::PolicyTransition,
manifest,
)
.unwrap();
- let mut outer: ManifestEnvelope = serde_json::from_slice(&bytes).unwrap();
- let mut header: ManifestHeader =
- serde_json::from_slice(&BASE64.decode(&outer.header).unwrap()).unwrap();
- header.generation_key_envelopes.pop();
- outer.header = BASE64.encode(serde_json::to_vec(&header).unwrap());
- let missing = serde_json::to_vec(&outer).unwrap();
- assert!(read_manifest_header(&missing, &policy, Some(&parent)).is_err());
-
- let mut outer: ManifestEnvelope = serde_json::from_slice(&bytes).unwrap();
- let mut header: ManifestHeader =
- serde_json::from_slice(&BASE64.decode(&outer.header).unwrap()).unwrap();
- header
- .generation_key_envelopes
- .push(header.generation_key_envelopes[0].clone());
- outer.header = BASE64.encode(serde_json::to_vec(&header).unwrap());
- let duplicate = serde_json::to_vec(&outer).unwrap();
- assert!(read_manifest_header(&duplicate, &policy, Some(&parent)).is_err());
+ let error = unwrap_generation_key(&peek_manifest_header(&encrypted).unwrap(), &outsider)
+ .unwrap_err()
+ .to_string();
+ assert!(error.contains("not an active reader of the current generation"));
}
#[test]
- fn signed_extra_envelope_for_revoked_reader_is_rejected() {
+ fn head_envelope_list_is_bounded_before_trial_decryption() {
let owner = KeyFile::generate();
- let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap();
- let (genesis, _) = PolicyState::genesis(&owner).unwrap();
- let with_reader = add_reader(&owner, &genesis, &reader);
- let mut devices = with_reader.body.devices.clone();
- devices
- .iter_mut()
- .find(|device| device.public.device_id == reader.device_id().unwrap())
- .unwrap()
- .revoked_at = Some(with_reader.body.generation + 1);
- let (revoked, _) = PolicyState::successor(&with_reader, devices, &owner).unwrap();
- revoked.validate_successor(&with_reader).unwrap();
-
- let generation_key = random_key();
- let manifest = Manifest {
+ let header = ManifestHeader {
format_version: FORMAT_VERSION,
repository_root: owner.repository_root.clone(),
- generation: 2,
- previous: Some("00".repeat(32)),
- policy_id: revoked.id.clone(),
- policy_generation: revoked.body.generation,
- authorization: ManifestAuthorization::PolicyTransition,
- total_pack_count: 0,
- refs: BTreeMap::new(),
- new_packs: Vec::new(),
- predecessor_key_wrap: Some(BASE64.encode([0_u8; 64])),
+ generation: 0,
+ previous: None,
+ key_commitment: "00".repeat(32),
+ generation_key_envelopes: vec![
+ GenerationKeyEnvelope {
+ encapsulated_key: String::new(),
+ ciphertext: String::new(),
+ };
+ MAX_GENERATION_ENVELOPES * 2
+ ],
+ body_digest: "11".repeat(32),
+ sealed_header_digest: "22".repeat(32),
};
- let bytes = seal_manifest(
- &owner,
- &revoked,
- &generation_key,
- ManifestAuthorization::PolicyTransition,
- manifest,
- )
- .unwrap();
- let mut outer: ManifestEnvelope = serde_json::from_slice(&bytes).unwrap();
- let mut header: ManifestHeader =
- serde_json::from_slice(&BASE64.decode(&outer.header).unwrap()).unwrap();
- let public = reader.public_device().unwrap();
- let aad = generation_key_aad(
- &header.repository_root,
- header.generation,
- &header.policy_id,
- &public.device_id,
- &header.key_commitment,
- );
- let (encapsulated, ciphertext) =
- wrap_generation_key(&public.wrapping_public_key, &generation_key, &aad).unwrap();
- header.generation_key_envelopes.push(GenerationKeyEnvelope {
- device_id: public.device_id,
- encapsulated_key: BASE64.encode(encapsulated),
- ciphertext: BASE64.encode(ciphertext),
- });
- header
- .generation_key_envelopes
- .sort_by(|a, b| a.device_id.cmp(&b.device_id));
- let exact_header = serde_json::to_vec(&header).unwrap();
- let body_ciphertext = BASE64.decode(&outer.body_ciphertext).unwrap();
- outer.header = BASE64.encode(&exact_header);
- outer.signature = BASE64.encode(
- owner
- .sign_domain(
- MANIFEST_SIGNATURE_DOMAIN,
- &manifest_signed_bytes(&exact_header, &body_ciphertext),
- )
- .unwrap(),
- );
- let malicious = serde_json::to_vec(&outer).unwrap();
- let error = read_manifest_header(&malicious, &revoked, Some(&with_reader))
+ let error = unwrap_generation_key(&header, &owner)
.unwrap_err()
.to_string();
- assert!(
- error.contains("must match active readers exactly"),
- "unexpected error: {error}"
- );
+ assert!(error.contains("invalid padded generation envelope count"));
}
#[test]
- fn wrong_recipient_key_is_rejected_by_signed_commitment() {
+ fn audit_rejects_missing_duplicate_extra_and_revoked_reader_entries() {
let owner = KeyFile::generate();
- let (policy, _) = PolicyState::genesis(&owner).unwrap();
- let intended = random_key();
- let wrong = random_key();
- let manifest = Manifest::genesis(owner.repository_root.clone(), &policy);
- let bytes = seal_manifest(
- &owner,
- &policy,
- &intended,
- ManifestAuthorization::PolicyTransition,
- manifest,
- )
- .unwrap();
- let mut outer: ManifestEnvelope = serde_json::from_slice(&bytes).unwrap();
- let mut header: ManifestHeader =
- serde_json::from_slice(&BASE64.decode(&outer.header).unwrap()).unwrap();
- let PublicDevice {
- wrapping_public_key,
- device_id,
- ..
- } = owner.public_device().unwrap();
- let aad = generation_key_aad(
- &header.repository_root,
- header.generation,
- &header.policy_id,
- &device_id,
- &header.key_commitment,
+ let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap();
+ let (parent, _) = PolicyState::genesis(&owner).unwrap();
+ let policy = add_reader(&owner, &parent, &reader);
+ let generation_key = random_key();
+ let valid = writer_manifest(&owner, &policy, &generation_key);
+ let (real_indices, dummy_index) = {
+ let opened = open_manifest(&valid, &generation_key).unwrap();
+ let outer: ManifestEnvelope = serde_json::from_slice(&valid).unwrap();
+ let header: ManifestHeader =
+ serde_json::from_slice(&BASE64.decode(outer.header).unwrap()).unwrap();
+ let sealed_ciphertext = BASE64.decode(outer.sealed_header_ciphertext).unwrap();
+ let key = derive_subkey(
+ &generation_key,
+ &header.repository_root,
+ header.generation,
+ SubkeyKind::SealedHeader,
+ 0,
+ )
+ .unwrap();
+ let opened_sealed = open_with_key(
+ &key,
+ &sealed_ciphertext,
+ &serde_json::to_vec(&header).unwrap(),
+ )
+ .unwrap();
+ let sealed: SealedHeaderEnvelope = serde_json::from_slice(&opened_sealed).unwrap();
+ let content: SealedHeaderContent =
+ serde_json::from_slice(&BASE64.decode(sealed.content).unwrap()).unwrap();
+ let real = content
+ .envelope_audit
+ .iter()
+ .enumerate()
+ .filter(|(_, entry)| matches!(entry, EnvelopeAuditEntry::Recipient { .. }))
+ .map(|(index, _)| index)
+ .collect::>();
+ let dummy = content
+ .envelope_audit
+ .iter()
+ .position(|entry| matches!(entry, EnvelopeAuditEntry::Dummy))
+ .unwrap();
+ assert_eq!(opened.manifest.policy_id, policy.id);
+ (real, dummy)
+ };
+ let missing = rewrite_signed_manifest(&valid, &owner, &generation_key, |_, audit| {
+ audit.envelope_audit.pop();
+ });
+ assert!(
+ verify_manifest(
+ &open_manifest(&missing, &generation_key).unwrap(),
+ &policy,
+ None,
+ &generation_key
+ )
+ .is_err()
);
- let (encapsulated, ciphertext) =
- wrap_generation_key(&wrapping_public_key, &wrong, &aad).unwrap();
- header.generation_key_envelopes = vec![GenerationKeyEnvelope {
- device_id,
- encapsulated_key: BASE64.encode(encapsulated),
- ciphertext: BASE64.encode(ciphertext),
- }];
- let exact_header = serde_json::to_vec(&header).unwrap();
- let body_ciphertext = BASE64.decode(&outer.body_ciphertext).unwrap();
- outer.header = BASE64.encode(&exact_header);
- outer.signature = BASE64.encode(
- owner
- .sign_domain(
- MANIFEST_SIGNATURE_DOMAIN,
- &manifest_signed_bytes(&exact_header, &body_ciphertext),
- )
- .unwrap(),
+
+ let duplicate = rewrite_signed_manifest(&valid, &owner, &generation_key, |_, audit| {
+ let duplicate_entry = audit.envelope_audit[real_indices[0]].clone();
+ audit.envelope_audit[real_indices[1]] = duplicate_entry;
+ });
+ assert!(
+ verify_manifest(
+ &open_manifest(&duplicate, &generation_key).unwrap(),
+ &policy,
+ None,
+ &generation_key
+ )
+ .is_err()
+ );
+
+ let extra = rewrite_signed_manifest(&valid, &owner, &generation_key, |_, audit| {
+ audit.envelope_audit[dummy_index] = EnvelopeAuditEntry::Recipient {
+ device_id: "ff".repeat(32),
+ encapsulation_seed: BASE64.encode([0_u8; 32]),
+ };
+ });
+ assert!(
+ verify_manifest(
+ &open_manifest(&extra, &generation_key).unwrap(),
+ &policy,
+ None,
+ &generation_key
+ )
+ .is_err()
+ );
+
+ let wrong_seed = rewrite_signed_manifest(&valid, &owner, &generation_key, |_, audit| {
+ let EnvelopeAuditEntry::Recipient {
+ encapsulation_seed, ..
+ } = &mut audit.envelope_audit[real_indices[1]]
+ else {
+ unreachable!()
+ };
+ let mut seed: [u8; 32] = BASE64
+ .decode(encapsulation_seed.as_str())
+ .unwrap()
+ .try_into()
+ .unwrap();
+ seed[0] ^= 1;
+ *encapsulation_seed = BASE64.encode(seed);
+ });
+ assert!(
+ verify_manifest(
+ &open_manifest(&wrong_seed, &generation_key).unwrap(),
+ &policy,
+ None,
+ &generation_key
+ )
+ .is_err()
+ );
+
+ let mut devices = policy.body.devices.clone();
+ devices
+ .iter_mut()
+ .find(|device| device.public.device_id == reader.device_id().unwrap())
+ .unwrap()
+ .revoked_at = Some(2);
+ let (revoked, _) = PolicyState::successor(&policy, devices, &owner).unwrap();
+ let revoked_entry = rewrite_signed_manifest(&valid, &owner, &generation_key, |_, audit| {
+ audit.policy_id = revoked.id.clone();
+ audit.policy_generation = revoked.body.generation;
+ });
+ assert!(
+ verify_manifest(
+ &open_manifest(&revoked_entry, &generation_key).unwrap(),
+ &revoked,
+ None,
+ &generation_key
+ )
+ .is_err()
);
- let malicious = serde_json::to_vec(&outer).unwrap();
- let verified = read_manifest_header(&malicious, &policy, None).unwrap();
- assert!(unwrap_generation_key(&verified, &owner).is_err());
}
}
diff --git a/src/policy.rs b/src/policy.rs
index eef6223..8ccceb9 100644
--- a/src/policy.rs
+++ b/src/policy.rs
@@ -10,8 +10,8 @@ use crate::crypto::{
verify_domain,
};
-pub const POLICY_FORMAT_VERSION: u32 = 3;
-const POLICY_SIGNATURE_DOMAIN: &[u8] = b"git-remote-e2ee policy v4\0";
+pub const POLICY_FORMAT_VERSION: u32 = 5;
+const POLICY_SIGNATURE_DOMAIN: &[u8] = b"git-remote-e2ee policy v5\0";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
diff --git a/src/repository.rs b/src/repository.rs
index df7afdc..ca0a21a 100644
--- a/src/repository.rs
+++ b/src/repository.rs
@@ -15,8 +15,8 @@ use crate::crypto::{
use crate::git;
use crate::manifest::{
Manifest, ManifestAuthorization, ManifestHeader, PackDescriptor, open_manifest, pack_aad,
- peek_manifest_header, read_manifest_header, seal_manifest, unwrap_generation_key,
- unwrap_predecessor_key, wrap_predecessor_key,
+ peek_manifest_header, seal_manifest, unwrap_generation_key, unwrap_predecessor_key,
+ verify_manifest, wrap_predecessor_key,
};
use crate::policy::{DeviceRecord, DeviceRoles, PolicyState};
use crate::storage::{ObjectKind, Storage};
@@ -63,10 +63,8 @@ impl EncryptedRepository {
bail!("encrypted repository is already initialized")
}
let (policy, policy_bytes) = PolicyState::genesis(&self.key)?;
- self.storage
- .put_object_if_absent(ObjectKind::Policy, &policy.id, &policy_bytes)?;
let generation_key = random_key();
- let genesis = Manifest::genesis(self.key.repository_root.clone(), &policy);
+ let genesis = Manifest::genesis(self.key.repository_root.clone(), &policy, policy_bytes);
self.commit_manifest(
None,
&policy,
@@ -83,8 +81,9 @@ impl EncryptedRepository {
roles: DeviceRoles,
pin_path: &Path,
) -> Result<(String, String)> {
- let current = self.current_state()?;
- self.validate_pinned_head(¤t, &read_client_state(pin_path)?)?;
+ let chain = self.current_chain()?;
+ let current = chain.first().context("manifest chain is empty")?.clone();
+ self.validate_pinned_head(&chain, &read_client_state(pin_path)?)?;
self.require_admin(¤t.policy)?;
if current.policy.device(&public.device_id).is_some() {
bail!("device already exists in policy")
@@ -98,14 +97,9 @@ impl EncryptedRepository {
let (next_policy, policy_bytes) =
PolicyState::successor(¤t.policy, devices, &self.key)?;
let next_key = random_key();
- let next = transition_manifest(¤t, &next_policy, &next_key)?;
- let result = self.publish_policy_transition(
- ¤t,
- &next_policy,
- &policy_bytes,
- &next_key,
- next.clone(),
- )?;
+ let next = transition_manifest(¤t, &next_policy, policy_bytes, &next_key)?;
+ let result =
+ self.publish_policy_transition(¤t, &next_policy, &next_key, next.clone())?;
write_client_state(
pin_path,
&HashSet::new(),
@@ -117,8 +111,9 @@ impl EncryptedRepository {
}
pub fn revoke_device(&self, device_id: &str, pin_path: &Path) -> Result<(String, String)> {
- let current = self.current_state()?;
- self.validate_pinned_head(¤t, &read_client_state(pin_path)?)?;
+ let chain = self.current_chain()?;
+ let current = chain.first().context("manifest chain is empty")?.clone();
+ self.validate_pinned_head(&chain, &read_client_state(pin_path)?)?;
self.require_admin(¤t.policy)?;
let mut devices = current.policy.body.devices.clone();
let target = devices
@@ -141,14 +136,9 @@ impl EncryptedRepository {
let (next_policy, policy_bytes) =
PolicyState::successor(¤t.policy, devices, &self.key)?;
let next_key = random_key();
- let next = transition_manifest(¤t, &next_policy, &next_key)?;
- let result = self.publish_policy_transition(
- ¤t,
- &next_policy,
- &policy_bytes,
- &next_key,
- next.clone(),
- )?;
+ let next = transition_manifest(¤t, &next_policy, policy_bytes, &next_key)?;
+ let result =
+ self.publish_policy_transition(¤t, &next_policy, &next_key, next.clone())?;
write_client_state(
pin_path,
&HashSet::new(),
@@ -160,9 +150,10 @@ impl EncryptedRepository {
}
pub fn pin_admin_state(&self, pin_path: &Path) -> Result<()> {
- let current = self.current_state()?;
+ let chain = self.current_chain()?;
+ let current = chain.first().context("manifest chain is empty")?;
let previous = read_client_state(pin_path)?;
- self.validate_pinned_head(¤t, &previous)?;
+ self.validate_pinned_head(&chain, &previous)?;
write_client_state(
pin_path,
&HashSet::new(),
@@ -173,19 +164,24 @@ impl EncryptedRepository {
}
pub fn list_devices(&self) -> Result> {
- Ok(self.current_state()?.policy.body.devices)
+ Ok(self
+ .current_chain()?
+ .into_iter()
+ .next()
+ .context("manifest chain is empty")?
+ .policy
+ .body
+ .devices)
}
fn publish_policy_transition(
&self,
current: &OpenedState,
policy: &PolicyState,
- policy_bytes: &[u8],
generation_key: &[u8; 32],
manifest: Manifest,
) -> Result<(String, String)> {
- self.storage
- .put_object_if_absent(ObjectKind::Policy, &policy.id, policy_bytes)?;
+ policy.validate_successor(¤t.policy)?;
let manifest_id = self.commit_manifest(
Some(current),
policy,
@@ -300,6 +296,7 @@ impl EncryptedRepository {
refs: next_refs,
new_packs: vec![descriptor],
predecessor_key_wrap: Some(predecessor_key_wrap),
+ introduced_policy: None,
};
let next_id = self.commit_manifest(
Some(¤t),
@@ -350,16 +347,13 @@ impl EncryptedRepository {
bail!("only refs/heads/* destinations are supported")
}
git::ensure_repository(repo)?;
- let current = self.current_state()?;
+ let chain = self.current_chain()?;
+ let current = chain.first().context("manifest chain is empty")?.clone();
if !current.policy.is_active_writer(&self.key.device_id()?) {
bail!("device is not an active writer")
}
if let Some(remote_name) = remote_name {
- self.validate_remote_continuity(repo, remote_name, ¤t)?;
- } else {
- // Direct callers do not have a clone-local pin, so validate the
- // complete signed and encrypted history before extending it.
- self.walk_chain(current.clone(), None)?;
+ self.validate_remote_continuity(repo, remote_name, &chain)?;
}
let next_object = git::resolve_ref(repo, source_ref)?;
if current.manifest.refs.get(destination_ref) == Some(&next_object) {
@@ -394,27 +388,28 @@ impl EncryptedRepository {
pub fn observe_manifest(&self, repo: &Path, remote_name: &str) -> Result {
git::ensure_repository(repo)?;
validate_remote_name(remote_name)?;
- let current = self.current_state()?;
+ let chain = self.current_chain()?;
+ let current = chain.first().context("manifest chain is empty")?.clone();
let state_path = client_state_path(repo, remote_name)?;
let state = read_client_state(&state_path)?;
- self.validate_pinned_head(¤t, &state)?;
+ self.validate_pinned_head(&chain, &state)?;
write_observed_client_state(&state_path, state, ¤t.id, ¤t.manifest)?;
- Ok(current.manifest)
+ Ok(current.manifest.clone())
}
pub fn import_packs(&self, repo: &Path, remote_name: &str) -> Result {
git::ensure_repository(repo)?;
validate_remote_name(remote_name)?;
- let current = self.current_state()?;
+ let chain = self.current_chain()?;
+ let current = chain.first().context("manifest chain is empty")?.clone();
let state_path = client_state_path(repo, remote_name)?;
let state = read_client_state(&state_path)?;
- self.validate_pinned_head(¤t, &state)?;
+ self.validate_pinned_head(&chain, &state)?;
let stop_generation = state.imported_generation;
- let chain = self.walk_chain(current.clone(), stop_generation)?;
let mut imported: HashSet = state.packs.into_iter().collect();
for entry in chain.iter().rev() {
- if stop_generation == Some(entry.manifest.generation) {
+ if stop_generation.is_some_and(|generation| entry.manifest.generation <= generation) {
continue;
}
for descriptor in &entry.manifest.new_packs {
@@ -464,10 +459,10 @@ impl EncryptedRepository {
&self,
repo: &Path,
remote_name: &str,
- current: &OpenedState,
+ chain: &[OpenedState],
) -> Result<()> {
let state = read_client_state(&client_state_path(repo, remote_name)?)?;
- self.validate_pinned_head(current, &state)
+ self.validate_pinned_head(chain, &state)
}
fn pin_manifest(
@@ -482,7 +477,8 @@ impl EncryptedRepository {
write_observed_client_state(&state_path, state, head_id, manifest)
}
- fn validate_pinned_head(&self, current: &OpenedState, state: &ClientState) -> Result<()> {
+ fn validate_pinned_head(&self, chain: &[OpenedState], state: &ClientState) -> Result<()> {
+ let current = chain.first().context("manifest chain is empty")?;
if let Some(root) = &state.repository_root
&& root != ¤t.manifest.repository_root
{
@@ -496,7 +492,6 @@ impl EncryptedRepository {
let (Some(pinned_id), Some(pinned_generation)) =
(state.head_id.as_deref(), state.generation)
else {
- self.walk_chain(current.clone(), None)?;
return Ok(());
};
if current.manifest.generation < pinned_generation {
@@ -505,9 +500,9 @@ impl EncryptedRepository {
current.manifest.generation
)
}
- let chain = self.walk_chain(current.clone(), Some(pinned_generation))?;
let pinned = chain
- .last()
+ .iter()
+ .find(|entry| entry.manifest.generation == pinned_generation)
.context("remote manifest chain ended before pinned generation")?;
if pinned.id != pinned_id {
bail!("remote manifest history forked from the locally pinned head")
@@ -516,8 +511,8 @@ impl EncryptedRepository {
}
pub fn verify(&self) -> Result {
- let current = self.current_state()?;
- let chain = self.walk_chain(current.clone(), None)?;
+ let chain = self.current_chain()?;
+ let current = chain.first().context("manifest chain is empty")?;
let mut count = 0_u64;
for entry in chain.iter().rev() {
for pack in &entry.manifest.new_packs {
@@ -547,137 +542,120 @@ impl EncryptedRepository {
if count != current.manifest.total_pack_count {
bail!("manifest delta log pack count mismatch")
}
- Ok(current.manifest)
+ Ok(current.manifest.clone())
}
pub fn current_manifest(&self) -> Result<(String, Manifest)> {
- let current = self.current_state()?;
+ let current = self
+ .current_chain()?
+ .into_iter()
+ .next()
+ .context("manifest chain is empty")?;
Ok((current.id, current.manifest))
}
- fn current_state(&self) -> Result {
+ fn current_chain(&self) -> Result> {
let head = self
.storage
.read_head()?
.context("encrypted repository is not initialized")?;
- let (encrypted, header, policy, parent_policy) = self.read_manifest_material(&head)?;
- let generation_key = unwrap_generation_key(&header, &self.key)?;
- let manifest = open_manifest(&encrypted, &policy, parent_policy.as_ref(), &generation_key)?;
- if manifest.generation == 0 {
- manifest.validate_genesis(&policy)?;
- }
- Ok(OpenedState {
- id: head,
- manifest,
- header,
- policy,
- generation_key,
- })
- }
-
- fn walk_chain(
- &self,
- mut current: OpenedState,
- stop_generation: Option,
- ) -> Result> {
- let mut chain = Vec::new();
+ let (mut id, mut encrypted, mut header) = self.read_manifest_material(&head)?;
+ let mut generation_key = unwrap_generation_key(&header, &self.key)?;
+ let mut opened = open_manifest(&encrypted, &generation_key)?;
+ let mut unverified = Vec::new();
let mut seen = HashSet::new();
- let mut seen_pack_ids = HashSet::new();
+
loop {
- if !seen.insert(current.id.clone()) {
+ if !seen.insert(id.clone()) {
bail!("manifest chain contains a cycle")
}
- if stop_generation.is_some_and(|generation| current.manifest.generation < generation) {
- bail!("manifest chain skipped below requested generation")
- }
- for pack in ¤t.manifest.new_packs {
- if !seen_pack_ids.insert(pack.id.clone()) {
- bail!("manifest chain repeats pack {}", pack.id)
+ let Some(previous_id) = opened.manifest.previous.clone() else {
+ if opened.manifest.generation != 0 {
+ bail!("manifest chain terminated above generation zero")
}
- }
- let should_stop = stop_generation == Some(current.manifest.generation);
- let is_genesis = current.manifest.generation == 0;
- chain.push(current.clone());
- if should_stop {
+ unverified.push((id, opened, generation_key));
break;
+ };
+ let (parent_id, parent_encrypted, parent_header) =
+ self.read_manifest_material(&previous_id)?;
+ let parent_key =
+ unwrap_predecessor_key(&generation_key, &opened.manifest, &parent_header)?;
+ unverified.push((id, opened, generation_key));
+ id = parent_id;
+ encrypted = parent_encrypted;
+ header = parent_header;
+ generation_key = parent_key;
+ opened = open_manifest(&encrypted, &generation_key)?;
+ if opened.header != header {
+ bail!("predecessor manifest header changed while opening")
}
- if is_genesis {
- if stop_generation.is_some() {
- bail!("manifest chain ended before requested generation")
+ }
+
+ let mut chain_oldest_first: Vec = Vec::with_capacity(unverified.len());
+ let mut seen_pack_ids = HashSet::new();
+ for (index, (id, opened, generation_key)) in unverified.into_iter().rev().enumerate() {
+ let manifest = &opened.manifest;
+ for pack in &manifest.new_packs {
+ if !seen_pack_ids.insert(pack.id.clone()) {
+ bail!("manifest chain repeats pack {}", pack.id)
}
- current.manifest.validate_genesis(¤t.policy)?;
- break;
}
-
- let previous_id = current
- .manifest
- .previous
- .clone()
- .context("manifest chain terminated above generation zero")?;
- let (encrypted, previous_header, previous_policy, previous_parent_policy) =
- self.read_manifest_material(&previous_id)?;
- let previous_key = unwrap_predecessor_key(
- ¤t.generation_key,
- ¤t.manifest,
- &previous_header,
- )?;
- let previous_manifest = open_manifest(
- &encrypted,
- &previous_policy,
- previous_parent_policy.as_ref(),
- &previous_key,
- )?;
- current.manifest.validate_successor(
- &previous_id,
- &previous_manifest,
- ¤t.policy,
- )?;
- current = OpenedState {
- id: previous_id,
- manifest: previous_manifest,
- header: previous_header,
- policy: previous_policy,
- generation_key: previous_key,
+ let (policy, parent_policy) = if index == 0 {
+ let policy_bytes = manifest
+ .introduced_policy
+ .as_deref()
+ .context("genesis manifest is missing its introduced policy")?;
+ let policy = PolicyState::parse(policy_bytes)?;
+ policy.validate_genesis(&self.key.repository_root)?;
+ manifest.validate_genesis(&policy)?;
+ (policy, None)
+ } else {
+ let parent = chain_oldest_first
+ .last()
+ .context("manifest parent is missing")?;
+ let (policy, parent_policy) = match manifest.authorization {
+ ManifestAuthorization::Writer => {
+ if manifest.introduced_policy.is_some() {
+ bail!("ordinary manifest unexpectedly introduces a policy")
+ }
+ (parent.policy.clone(), None)
+ }
+ ManifestAuthorization::PolicyTransition => {
+ let bytes = manifest
+ .introduced_policy
+ .as_deref()
+ .context("policy transition is missing its introduced policy")?;
+ let policy = PolicyState::parse(bytes)?;
+ policy.validate_successor(&parent.policy)?;
+ (policy, Some(parent.policy.clone()))
+ }
+ ManifestAuthorization::Checkpoint => {
+ bail!("checkpoint transitions are reserved but not implemented")
+ }
+ };
+ manifest.validate_successor(&parent.id, &parent.manifest, &policy)?;
+ (policy, parent_policy)
};
+ verify_manifest(&opened, &policy, parent_policy.as_ref(), &generation_key)?;
+ chain_oldest_first.push(OpenedState {
+ id,
+ manifest: opened.manifest,
+ header: opened.header,
+ policy,
+ generation_key,
+ });
}
- Ok(chain)
+ chain_oldest_first.reverse();
+ Ok(chain_oldest_first)
}
- fn read_manifest_material(
- &self,
- id: &str,
- ) -> Result<(Vec, ManifestHeader, PolicyState, Option)> {
+ fn read_manifest_material(&self, id: &str) -> Result<(String, Vec, ManifestHeader)> {
let bytes = self.storage.get_object(ObjectKind::Manifest, id)?;
if object_id(&bytes) != id {
bail!("manifest ciphertext hash mismatch")
}
- let unverified = peek_manifest_header(&bytes)?;
- let policy = self.read_policy(&unverified.policy_id)?;
- let parent = match &policy.body.previous {
- Some(parent_id) => Some(self.read_policy(parent_id)?),
- None => None,
- };
- let header = read_manifest_header(&bytes, &policy, parent.as_ref())?;
- Ok((bytes, header, policy, parent))
- }
-
- fn read_policy(&self, id: &str) -> Result {
- let bytes = self.storage.get_object(ObjectKind::Policy, id)?;
- if object_id(&bytes) != id {
- bail!("policy object hash mismatch")
- }
- let policy = PolicyState::parse(&bytes)?;
- if policy.id != id {
- bail!("policy id mismatch")
- }
- match &policy.body.previous {
- Some(previous_id) => {
- let parent = self.read_policy(previous_id)?;
- policy.validate_successor(&parent)?;
- }
- None => policy.validate_genesis(&self.key.repository_root)?,
- }
- Ok(policy)
+ let header = peek_manifest_header(&bytes)?;
+ Ok((id.to_owned(), bytes, header))
}
fn commit_manifest(
@@ -706,15 +684,16 @@ impl EncryptedRepository {
self.storage
.put_object_if_absent(ObjectKind::Manifest, &id, &encrypted)?;
- let header = read_manifest_header(&encrypted, policy, parent_policy)?;
- let opened = open_manifest(&encrypted, policy, parent_policy, generation_key)?;
+ let opened = open_manifest(&encrypted, generation_key)?;
+ verify_manifest(&opened, policy, parent_policy, generation_key)?;
if let Some(previous) = previous {
- let recovered = unwrap_predecessor_key(generation_key, &opened, &previous.header)?;
+ let recovered =
+ unwrap_predecessor_key(generation_key, &opened.manifest, &previous.header)?;
if *recovered != *previous.generation_key {
bail!("new manifest predecessor link did not recover the parent key")
}
}
- if header.generation != manifest.generation {
+ if opened.header.generation != manifest.generation {
bail!("new manifest self-check changed generation")
}
self.storage
@@ -726,6 +705,7 @@ impl EncryptedRepository {
fn transition_manifest(
current: &OpenedState,
policy: &PolicyState,
+ policy_bytes: Vec,
generation_key: &[u8; 32],
) -> Result {
let generation = current.manifest.generation + 1;
@@ -747,6 +727,7 @@ fn transition_manifest(
generation,
¤t.id,
)?),
+ introduced_policy: Some(policy_bytes),
})
}
@@ -775,19 +756,14 @@ fn client_state_path(repo: &Path, remote_name: &str) -> Result Result {
match fs::read(path) {
Ok(contents) => {
- let mut state: ClientState =
+ let state: ClientState =
serde_json::from_slice(&contents).context("parse git-remote-e2ee client state")?;
- if state.format_version > 4 {
+ if state.format_version != 5 {
bail!(
"unsupported git-remote-e2ee client state format {}",
state.format_version
)
}
- // v2 client states used the observed generation as the import
- // checkpoint because observation and import were one operation.
- if state.format_version < 3 && state.imported_generation.is_none() {
- state.imported_generation = state.generation;
- }
Ok(state)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(ClientState::default()),
@@ -805,7 +781,7 @@ fn write_client_state(
let mut values: Vec<_> = values.iter().cloned().collect();
values.sort();
let state = ClientState {
- format_version: 4,
+ format_version: 5,
packs: values,
head_id: Some(head_id.to_owned()),
generation: Some(manifest.generation),
@@ -824,7 +800,7 @@ fn write_observed_client_state(
manifest: &Manifest,
) -> Result<()> {
let state = ClientState {
- format_version: 4,
+ format_version: 5,
packs: previous.packs,
head_id: Some(head_id.to_owned()),
generation: Some(manifest.generation),
@@ -871,7 +847,7 @@ pub(crate) fn testing_write_client_state(
crate::persist::create_dir_all_durable(parent)?;
}
let state = ClientState {
- format_version: 4,
+ format_version: 5,
packs: Vec::new(),
head_id: Some(head_id.to_owned()),
generation: Some(generation),
diff --git a/src/storage.rs b/src/storage.rs
index c753e49..1cb42a3 100644
--- a/src/storage.rs
+++ b/src/storage.rs
@@ -95,7 +95,6 @@ pub trait Storage {
pub enum ObjectKind {
Pack,
Manifest,
- Policy,
}
impl ObjectKind {
@@ -103,7 +102,6 @@ impl ObjectKind {
match self {
Self::Pack => "objects",
Self::Manifest => "manifests",
- Self::Policy => "policies",
}
}
}
@@ -119,7 +117,7 @@ impl FilesystemStorage {
}
pub fn initialize(&self) -> Result<()> {
- for name in ["objects", "manifests", "policies"] {
+ for name in ["objects", "manifests"] {
let directory = self.root.join(name);
crate::persist::create_dir_all_durable(&directory)
.with_context(|| format!("create {}", directory.display()))?;
diff --git a/tests/anonymous_recipients.rs b/tests/anonymous_recipients.rs
new file mode 100644
index 0000000..0c5ad07
--- /dev/null
+++ b/tests/anonymous_recipients.rs
@@ -0,0 +1,211 @@
+use std::fs;
+use std::io::Write;
+use std::path::Path;
+use std::process::{Command, Stdio};
+
+use base64::Engine;
+use git_remote_e2ee::crypto::{KeyFile, object_id};
+use git_remote_e2ee::manifest::{ManifestHeader, peek_manifest_header};
+use git_remote_e2ee::policy::DeviceRoles;
+use git_remote_e2ee::repository::EncryptedRepository;
+use git_remote_e2ee::storage::{FilesystemStorage, GitStorage, Storage};
+
+#[test]
+fn filesystem_storage_hides_device_metadata_and_pads_reader_envelopes() {
+ let temporary = tempfile::tempdir().unwrap();
+ let root = temporary.path().join("storage");
+ run_metadata_scenario(
+ || FilesystemStorage::new(&root),
+ || filesystem_snapshot(&root),
+ || assert!(!root.join("policies").exists()),
+ temporary.path(),
+ );
+}
+
+#[test]
+fn carrier_history_hides_device_metadata_and_pads_reader_envelopes() {
+ let temporary = tempfile::tempdir().unwrap();
+ let carrier = temporary.path().join("carrier.git");
+ let output = Command::new("git")
+ .args(["init", "--bare", "-q"])
+ .arg(&carrier)
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "git init --bare: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ run_metadata_scenario(
+ || GitStorage::open(carrier.to_str().unwrap()).unwrap(),
+ || carrier_snapshot(&carrier),
+ || {
+ let paths = git_output(&carrier, &["log", "--all", "--name-only", "--format="]);
+ assert!(!String::from_utf8_lossy(&paths.stdout).contains("e2ee/policies/"));
+ },
+ temporary.path(),
+ );
+}
+
+fn run_metadata_scenario(
+ make_storage: impl Fn() -> S,
+ snapshot: impl Fn() -> Vec,
+ no_policy_objects: impl Fn(),
+ scratch: &Path,
+) {
+ let owner = KeyFile::generate();
+ let pin = scratch.join("owner.admin-state.json");
+ let repository = EncryptedRepository::new(make_storage(), owner.clone());
+ repository.initialize().unwrap();
+ repository.pin_admin_state(&pin).unwrap();
+ let mut devices = vec![owner.public_device().unwrap()];
+ assert_eq!(
+ stored_header(make_storage()).generation_key_envelopes.len(),
+ 4
+ );
+ assert_storage_is_anonymous(&snapshot(), &devices);
+ no_policy_objects();
+
+ let readers: Vec<_> = (0..4)
+ .map(|_| KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap())
+ .collect();
+ for (index, reader) in readers.iter().enumerate() {
+ repository
+ .add_device(
+ reader.public_device().unwrap(),
+ DeviceRoles::collaborator(),
+ &pin,
+ )
+ .unwrap();
+ devices.push(reader.public_device().unwrap());
+ let expected = if index == 3 { 8 } else { 4 };
+ assert_eq!(
+ stored_header(make_storage()).generation_key_envelopes.len(),
+ expected
+ );
+ assert_storage_is_anonymous(&snapshot(), &devices);
+ no_policy_objects();
+ }
+
+ repository
+ .revoke_device(&readers[0].device_id().unwrap(), &pin)
+ .unwrap();
+ assert_eq!(
+ stored_header(make_storage()).generation_key_envelopes.len(),
+ 4
+ );
+ assert_storage_is_anonymous(&snapshot(), &devices);
+ no_policy_objects();
+}
+
+fn stored_header(storage: impl Storage) -> ManifestHeader {
+ let head = storage.read_head().unwrap().unwrap();
+ let bytes = storage
+ .get_object(git_remote_e2ee::storage::ObjectKind::Manifest, &head)
+ .unwrap();
+ assert_eq!(object_id(&bytes), head);
+ peek_manifest_header(&bytes).unwrap()
+}
+
+fn assert_storage_is_anonymous(bytes: &[u8], devices: &[git_remote_e2ee::crypto::PublicDevice]) {
+ let mut tokens = vec![
+ b"reader".to_vec(),
+ b"writer".to_vec(),
+ b"administrator".to_vec(),
+ b"signing_public_key".to_vec(),
+ b"wrapping_public_key".to_vec(),
+ b"device_id".to_vec(),
+ b"policies/".to_vec(),
+ ];
+ for device in devices {
+ tokens.push(device.device_id.as_bytes().to_vec());
+ tokens.push(hex::decode(&device.device_id).unwrap());
+ tokens.push(device.signing_public_key.as_bytes().to_vec());
+ tokens.push(
+ base64::engine::general_purpose::STANDARD
+ .decode(&device.signing_public_key)
+ .unwrap(),
+ );
+ tokens.push(device.wrapping_public_key.as_bytes().to_vec());
+ tokens.push(
+ base64::engine::general_purpose::STANDARD
+ .decode(&device.wrapping_public_key)
+ .unwrap(),
+ );
+ }
+ for token in tokens {
+ assert!(
+ !bytes.windows(token.len()).any(|window| window == token),
+ "storage contains plaintext metadata token {:?}",
+ String::from_utf8_lossy(&token)
+ );
+ }
+}
+
+fn filesystem_snapshot(root: &Path) -> Vec {
+ let mut bytes = Vec::new();
+ append_files(root, root, &mut bytes);
+ bytes
+}
+
+fn carrier_snapshot(root: &Path) -> Vec {
+ let mut bytes = Vec::new();
+ append_files(root, root, &mut bytes);
+
+ let reachable = git_output(root, &["rev-list", "--objects", "--all"]);
+ assert!(
+ reachable.status.success(),
+ "git rev-list: {}",
+ String::from_utf8_lossy(&reachable.stderr)
+ );
+ let ids = String::from_utf8(reachable.stdout).unwrap();
+ let mut child = Command::new("git")
+ .arg(format!("--git-dir={}", root.display()))
+ .args(["cat-file", "--batch"])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .unwrap();
+ {
+ let stdin = child.stdin.as_mut().unwrap();
+ for line in ids.lines() {
+ let id = line.split_whitespace().next().unwrap();
+ writeln!(stdin, "{id}").unwrap();
+ }
+ }
+ let output = child.wait_with_output().unwrap();
+ assert!(
+ output.status.success(),
+ "git cat-file: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ bytes.extend_from_slice(&output.stdout);
+ bytes
+}
+
+fn append_files(root: &Path, directory: &Path, bytes: &mut Vec) {
+ let mut entries: Vec<_> = fs::read_dir(directory)
+ .unwrap()
+ .map(Result::unwrap)
+ .collect();
+ entries.sort_by_key(|entry| entry.file_name());
+ for entry in entries {
+ let path = entry.path();
+ let relative = path.strip_prefix(root).unwrap();
+ bytes.extend_from_slice(relative.to_string_lossy().as_bytes());
+ if entry.file_type().unwrap().is_dir() {
+ append_files(root, &path, bytes);
+ } else {
+ bytes.extend_from_slice(&fs::read(&path).unwrap());
+ }
+ }
+}
+
+fn git_output(root: &Path, args: &[&str]) -> std::process::Output {
+ Command::new("git")
+ .arg(format!("--git-dir={}", root.display()))
+ .args(args)
+ .output()
+ .unwrap()
+}
diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs
index b213641..91ced98 100644
--- a/tests/end_to_end.rs
+++ b/tests/end_to_end.rs
@@ -5,7 +5,7 @@ use std::process::Command;
use git_remote_e2ee::crypto::{KeyFile, object_id, random_key};
use git_remote_e2ee::manifest::{
Manifest, ManifestAuthorization, open_manifest, peek_manifest_header, seal_manifest,
- unwrap_generation_key, wrap_predecessor_key,
+ unwrap_generation_key, verify_manifest, wrap_predecessor_key,
};
use git_remote_e2ee::policy::PolicyState;
use git_remote_e2ee::repository::EncryptedRepository;
@@ -157,11 +157,12 @@ fn returning_client_rejects_signed_ref_advance_without_its_pack() {
.to_owned();
let manifest_bytes = read_stored_object(&remote, "manifests", &head);
let header = peek_manifest_header(&manifest_bytes).unwrap();
- let policy_bytes = read_stored_object(&remote, "policies", &header.policy_id);
- let policy = PolicyState::parse(&policy_bytes).unwrap();
+ let (policy, _) = PolicyState::genesis(&key).unwrap();
policy.validate_genesis(&key.repository_root).unwrap();
let current_key = unwrap_generation_key(&header, &key).unwrap();
- let current = open_manifest(&manifest_bytes, &policy, None, ¤t_key).unwrap();
+ let current = open_manifest(&manifest_bytes, ¤t_key).unwrap();
+ verify_manifest(¤t, &policy, None, ¤t_key).unwrap();
+ let current = current.manifest().clone();
let next_key = random_key();
let generation = current.generation + 1;
@@ -188,6 +189,7 @@ fn returning_client_rejects_signed_ref_advance_without_its_pack() {
)
.unwrap(),
),
+ introduced_policy: None,
};
let malicious_bytes = seal_manifest(
&key,
@@ -440,17 +442,7 @@ fn rejects_same_generation_manifest_fork_after_fetch_pins_history() {
let fork = EncryptedRepository::new(FilesystemStorage::new(&fork_path), key);
fork.initialize().unwrap();
let fork_head = fork.push_ref(&source, "refs/heads/main", false).unwrap();
- let fork_manifest = fork_path
- .join("manifests")
- .join(&fork_head[..2])
- .join(&fork_head);
- let remote_manifest = remote_path
- .join("manifests")
- .join(&fork_head[..2])
- .join(&fork_head);
- fs::create_dir_all(remote_manifest.parent().unwrap()).unwrap();
- fs::copy(fork_manifest, remote_manifest).unwrap();
- copy_tree(&fork_path.join("policies"), &remote_path.join("policies"));
+ copy_tree(&fork_path.join("manifests"), &remote_path.join("manifests"));
fs::write(remote_path.join("HEAD"), format!("{fork_head}\n")).unwrap();
let error = encrypted.fetch_into(&destination, "encrypted").unwrap_err();
@@ -576,7 +568,7 @@ fn init_with_relative_storage_creates_the_store_in_the_child_cwd() {
);
assert!(work.join("relative-store/objects").is_dir());
assert!(work.join("relative-store/manifests").is_dir());
- assert!(work.join("relative-store/policies").is_dir());
+ assert!(!work.join("relative-store/policies").exists());
assert!(!root.path().join("relative-store").exists());
}
diff --git a/tests/remote_helper.rs b/tests/remote_helper.rs
index 0886c6f..73e013c 100644
--- a/tests/remote_helper.rs
+++ b/tests/remote_helper.rs
@@ -293,17 +293,7 @@ fn native_git_fetch_rejects_same_generation_manifest_fork() {
let fork = EncryptedRepository::new(FilesystemStorage::new(&fork_path), key);
fork.initialize().unwrap();
let fork_head = fork.push_ref(&source, "refs/heads/main", false).unwrap();
- let fork_manifest = fork_path
- .join("manifests")
- .join(&fork_head[..2])
- .join(&fork_head);
- let remote_manifest = remote_path
- .join("manifests")
- .join(&fork_head[..2])
- .join(&fork_head);
- fs::create_dir_all(remote_manifest.parent().unwrap()).unwrap();
- fs::copy(fork_manifest, remote_manifest).unwrap();
- copy_tree(&fork_path.join("policies"), &remote_path.join("policies"));
+ copy_tree(&fork_path.join("manifests"), &remote_path.join("manifests"));
fs::write(remote_path.join("HEAD"), format!("{fork_head}\n")).unwrap();
let output = git_output(&destination, &["fetch", "private"], true);
From 39574f6818074739db823a65124d9abc7070bec4 Mon Sep 17 00:00:00 2001
From: hmirin
Date: Thu, 1 Oct 2026 09:27:04 +0900
Subject: [PATCH 17/98] Document the v5 anonymous recipient format
---
README.ja.md | 6 +-
README.md | 13 ++--
docs/design.md | 89 ++++++++++++---------
docs/guide.md | 1 -
docs/spec.md | 207 ++++++++++++++++++++++++++++++++-----------------
5 files changed, 201 insertions(+), 115 deletions(-)
diff --git a/README.ja.md b/README.ja.md
index ea354aa..4f0d3dd 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -92,7 +92,7 @@ Git を暗号化するツールには2つの系統があります。**ファイ
| ブランチ名とコミットグラフ | 見える | 見える | 隠れる | 隠れる |
| 変更されたファイル・サイズ・同一ファイル | 見える | 見える¹ | 隠れる | 隠れる |
| push した時刻とおおよその量 | 見える | 見える | 見える | 見える |
-| 共同作業者 | アカウント一覧 | 鍵のフィンガープリント² | 人数のみ³ | 人数・公開鍵・権限⁴ |
+| 共同作業者 | アカウント一覧 | 鍵のフィンガープリント² | 人数のみ³ | 人数のみ(切り上げ)⁴ |
### 各ツールでできること
@@ -120,7 +120,7 @@ Git を暗号化するツールには2つの系統があります。**ファイ
1. git-crypt の README に、ファイルが変わったかどうか、長さ、2つのファイルが同一かどうかは隠さないと明記されています。
2. git-crypt の GPG モードでは、リポジトリ鍵を利用者ごとに暗号化し、鍵のフィンガープリントの名前で `.git-crypt/` 以下にコミットします。
3. gcrypt は既定で受信者の鍵 ID を隠します(`gpg -R`)。暗号化された鍵パケットの数はわかります。
-4. 各端末は復号の前に自分宛ての封筒を見つける必要があるため、ポリシーは平文の構造を持ちます。そのため保存先には、端末の数、リポジトリごとの公開鍵、権限、ポリシーの変更が見えます。端末鍵をリポジトリ間で使い回さないでください。
+4. 封筒の数は2のべき乗に切り上げ、最小4件にします。公開鍵、権限、メンバー変更は暗号化されますが、サイズの変化から変更を推測される場合があります。端末鍵をリポジトリ間で使い回さないでください。
5. gcrypt はローカルと rsync のバックエンドでは差分転送ですが、[ドキュメント](https://manpages.debian.org/trixie/git-remote-gcrypt/git-remote-gcrypt.1.en.html)によると Git や SFTP のバックエンドでは push のたびに履歴全体をアップロードします。また、予告なくリモートを repack することがあります。
6. push でアップロードするのは新しいデータだけですが、現在の実装は操作ごとに carrier リポジトリ全体を clone するので、永続キャッシュが入るまでは fetch や push のたびに暗号化された履歴全体もダウンロードします。ディレクトリを保存先にした場合は、どちら向きも差分です。
7. 暗号化したファイルは、変更すると前の版と無関係な暗号文になるため、Git が差分圧縮できません。
@@ -156,7 +156,7 @@ Git を暗号化するツールには2つの系統があります。**ファイ
- まったく新しい clone に古い履歴や分岐した履歴を見せること、クライアントごとに違う履歴を見せること(外部のアンカーが必要で、ロードマップにあります)
- 権限のある書き込み者による破壊的な変更
- 取り消された端末が、取り消し前に読めたものを読むこと
-- トラフィック分析(更新時刻、オブジェクトのサイズ、全体の増え方は見えます)
+- トラフィック分析(更新時刻、オブジェクトのサイズ、全体の増え方、切り上げ後の読み取り端末数は見えます)
- 将来の量子計算機による攻撃(鍵交換は X25519 です)
鍵は過去向きの鎖になっています。現在の鍵でそれ以前の履歴をすべて復号できるので、新しい端末もリポジトリ全体を読めますが、古い鍵でそれより新しいものは復号できません。これは key regression であって、すでに公開された履歴に対する前方秘匿性ではありません。脅威モデルの詳細は [docs/design.md](docs/design.md) と [docs/spec.md](docs/spec.md)、問題の報告は [SECURITY.md](SECURITY.md) を参照してください。
diff --git a/README.md b/README.md
index aa9e38a..fdd51a6 100644
--- a/README.md
+++ b/README.md
@@ -141,7 +141,7 @@ of selected files inside an otherwise normal repository. **Encrypted remotes**,
| 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, public keys, roles⁴ |
+| Collaborators | account list | key fingerprints² | count only³ | count only (padded)⁴ |
### What each tool can do
@@ -172,10 +172,10 @@ of selected files inside an otherwise normal repository. **Encrypted remotes**,
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. Each device must find its own envelope before it can decrypt anything, so
- the policy is plaintext-structured: the host sees the number of devices,
- their per-repository public keys, their roles, and policy changes. Do not
- reuse device keys between repositories.
+4. The envelope list is padded to the next power of two, with a minimum of
+ four. Public keys, roles, and membership changes are encrypted, though size
+ patterns may suggest a transition. Do not reuse device keys between
+ repositories.
5. 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
@@ -238,7 +238,8 @@ It does **not** prevent:
the roadmap);
- an authorized writer making destructive changes;
- a revoked device reading what it could read before revocation;
-- traffic analysis: update times, object sizes, and total growth are visible;
+- traffic analysis: update times, object sizes, total growth, and the padded
+ reader count are visible;
- future quantum attacks: key exchange uses X25519.
Keys work as a backward chain. The current key can decrypt all earlier
diff --git a/docs/design.md b/docs/design.md
index a6a1fb1..96b165d 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -1,6 +1,6 @@
# Design
-This document summarizes the implemented v4 architecture. [`spec.md`](spec.md)
+This document summarizes the implemented v5 architecture. [`spec.md`](spec.md)
defines the protocol invariants and security claims in detail.
## Goal
@@ -35,8 +35,7 @@ private key of a device that remains active.
```text
objects/ encrypted incremental Git pack
-manifests/
From c1059a20dfd1d2eb87581453d623047aad4f8be1 Mon Sep 17 00:00:00 2001
From: hmirin
Date: Thu, 1 Oct 2026 11:16:32 +0900
Subject: [PATCH 20/98] Point security reports at
git-remote-e2ee@combinatrix.ai
---
SECURITY.md | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/SECURITY.md b/SECURITY.md
index 42a014b..c8ea0bd 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,8 +1,7 @@
# Security
-Please report vulnerabilities privately through GitHub's
-[private vulnerability reporting](https://github.com/combinatrix-ai/git-remote-e2ee/security/advisories/new)
-rather than opening a public issue.
+Please report vulnerabilities privately to
+**git-remote-e2ee@combinatrix.ai** rather than opening a public issue.
`git-remote-e2ee` is an early prototype and has not been independently
audited. Its intended guarantees and known limits are listed in the
From d84c3db1fd657d1e35368dc3709de95bd6abe438 Mon Sep 17 00:00:00 2001
From: hmirin
Date: Thu, 1 Oct 2026 11:18:50 +0900
Subject: [PATCH 21/98] Move the carrier download caveat out of the pitch;
extend the roadmap
---
README.md | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index dc88582..447dee8 100644
--- a/README.md
+++ b/README.md
@@ -68,9 +68,7 @@ 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 little metadata (3–4 KiB in total for a tiny commit with one device),
on every backend. gcrypt re-uploads the entire history on every push to a Git
- or SFTP backend such as GitHub, and can repack without warning. One caveat
- today: with a Git host as storage, each operation first clones the whole
- encrypted carrier repository, so downloads are not yet incremental there.
+ or SFTP backend such as GitHub, and can repack without warning.
- **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
@@ -299,13 +297,15 @@ it.
## Status and roadmap
Working today: clone, fetch, pull, and push; the directory and carrier-Git
-backends; per-device keys; adding and revoking devices; incremental transfer;
+backends; per-device keys; adding and revoking devices; incremental uploads;
and rollback and race detection.
-Planned: M-of-N administrator approval, key recovery and replacement, automatic
+Planned: a persistent local cache so fetches from a Git host download only new
+data, 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, tags and branch deletion, shallow and partial clone,
-and optional transparency-log anchoring.
+optional transparency-log anchoring, and an optional file-level mode that
+encrypts only selected files while keeping per-device keys and revocation.
## Documentation
From 6d1ee972e6d81e4cb79c93173cb45d00e21ea682 Mon Sep 17 00:00:00 2001
From: hmirin
Date: Thu, 1 Oct 2026 11:49:30 +0900
Subject: [PATCH 22/98] Add persistent carrier object cache
---
src/storage.rs | 391 +++++++++++++++++++++++++++++-----
tests/anonymous_recipients.rs | 11 +
tests/carrier_git.rs | 319 ++++++++++++++++++++++++++-
3 files changed, 670 insertions(+), 51 deletions(-)
diff --git a/src/storage.rs b/src/storage.rs
index 1cb42a3..02dfff3 100644
--- a/src/storage.rs
+++ b/src/storage.rs
@@ -306,9 +306,12 @@ fn verify_file_id(path: &Path, id: &str) -> Result<()> {
const CARRIER_BRANCH: &str = "git-remote-e2ee";
const CARRIER_CHUNK_SIZE: usize = 32 * 1024 * 1024;
+const CARRIER_CACHE_ENV: &str = "GIT_REMOTE_E2EE_CACHE_DIR";
+const CARRIER_CACHE_FETCH_REF: &str = "refs/heads/git-remote-e2ee";
pub struct GitStorage {
checkout: tempfile::TempDir,
+ remote: String,
state: Mutex,
}
@@ -474,44 +477,32 @@ impl GitStorage {
if remote.is_empty() {
bail!("empty carrier Git remote")
}
- let checkout = tempfile::Builder::new()
+
+ let mut cache = CarrierCache::lock(remote)?;
+ let mut base_commit = cache.refresh(remote)?;
+ let mut checkout = tempfile::Builder::new()
.prefix("git-remote-e2ee-carrier-")
.tempdir()?;
- let checkout_path = checkout
- .path()
- .to_str()
- .context("carrier checkout path is not UTF-8")?;
- git_command(
- checkout
- .path()
- .parent()
- .context("carrier tempdir has no parent")?,
- &["clone", "--quiet", "--no-checkout", remote, checkout_path],
- )?;
- git_command(checkout.path(), &["config", "user.name", "git-remote-e2ee"])?;
- git_command(
- checkout.path(),
- &["config", "user.email", "git-remote-e2ee@invalid"],
- )?;
-
- let remote_ref = format!("refs/remotes/origin/{CARRIER_BRANCH}");
- let base_commit = git_rev_parse(checkout.path(), &remote_ref)?;
- match &base_commit {
- Some(_) => {
- git_command(
- checkout.path(),
- &["checkout", "--quiet", "-B", CARRIER_BRANCH, &remote_ref],
- )?;
- }
- None => {
- git_command(
- checkout.path(),
- &["checkout", "--quiet", "--orphan", CARRIER_BRANCH],
- )?;
+ if let Err(error) =
+ create_carrier_checkout(checkout.path(), &cache.path, remote, &base_commit)
+ {
+ if !cache.is_corrupt()? {
+ return Err(error).context("create carrier checkout");
}
+ drop(checkout);
+ cache.rebuild()?;
+ base_commit = cache.refresh(remote)?;
+ checkout = tempfile::Builder::new()
+ .prefix("git-remote-e2ee-carrier-")
+ .tempdir()?;
+ create_carrier_checkout(checkout.path(), &cache.path, remote, &base_commit)
+ .context("create carrier checkout after rebuilding corrupt cache")?;
}
+ drop(cache);
+
Ok(Self {
checkout,
+ remote: remote.to_owned(),
state: Mutex::new(GitStorageState { base_commit }),
})
}
@@ -543,21 +534,8 @@ impl GitStorage {
}
}
- fn remote_tip(&self) -> Result