Gunnar

nordisk/gunnar

.nornir/encrypted-repos.md

<!-- nornir:class: design -->
# Encrypted repositories

**Goal.** The operator of a gunnar server cannot read an encrypted repository's
content. Neither can its administrator, its twin, its backups or anyone who
takes its disks. Only the repository's members can read it, each with their
own encryption key, and several users can share one repository. A repository can also be
**quantum-safe** (§3.1): its key ring is then wrapped only to members'
post-quantum encryption keys, so a copy taken today stays closed after quantum computers
arrive. The server still sees file names and object ids, never content.

This document covers the threat model, the keys, the formats, the flows in the
CLI and the browser, what the server sees, what still leaks, rotation and
recovery. It also covers two levels:

* **Level 1** works on today's gunnar with stock git. A git filter encrypts
  file content on the client. The server stores ciphertext blobs. File names,
  commit messages, sizes and the shape of history stay visible.
* **Level 2** is gunnar-native. The repository is flagged "encrypted" and the
  server refuses plaintext. Object ids are keyed, so nobody without the key
  can confirm that a known file is in the repository. Later, trees and commit
  messages will be sealed as well.

The code lives in `crates/gunnar-crypt` (the format and the user key, shared
by every door), in `gunnar crypt …` (`crates/gunnar-cli/src/crypt.rs`,
`crypt_key.rs` and `fido2.rs`), in the server's push gate (`gunnar-policy`,
`gates/encrypted.rs`), in the store of people's encryption keys
(`gunnar-core` `encryption_key.rs`, the `Access` RPCs and the forge door
`gunnar-user-key`), and in the console's "open with your security key" view
(gunnar-ui).

**Since CRYPT-YUBI (owner, 2026-10-26)** there is ONE encryption key per
person (§3). A repository's ring is wrapped to members' encryption keys only;
the person's SSH keys, security keys and recovery code are UNLOCK METHODS of
that one key. The local `gunnar unlock` helper is gone. The old format, where
a ring was wrapped to SSH keys, is not read any more: there were no users, and
test repositories are made again (no migration, owner).

---

## 1. Threat model

**Defended against.** Each of these sees ciphertext only:

| who | what they have |
|---|---|
| the operator or admin of the gunnar server | the data root, the control plane, root on the box |
| the twin, the browse replica, every backup | byte copies of the same data root |
| someone who takes a disk or a backup tape | the same |
| the console server (gunnar-ui-server) | relays ciphertext; holds no key |
| a person with READ access but no key | clones ciphertext and cannot unlock it |
| a former member, for content written after removal | old keys only (see §6) |

**Not defended against.** These are said plainly in the product, not only here:

* **A member.** Any member can read everything and can copy it anywhere. That
  is what membership means.
* **A compromised client machine.** The key is on it.
* **What leaks by construction** (§8): sizes, timing, file counts and the
  shape of history; in Level 1 also file names and commit messages.
* **The server that delivers the console's code.** The browser runs wasm that
  the server sends. A malicious server could send wasm that leaks the
  person's encryption key, or a key ring, once the security key has opened
  them in the tab (§9). The CLI does not have this gap.
* **Denial.** The operator can delete or withhold the ciphertext. Twins,
  backups and every member's clone are the answer to that, not cryptography.
* **A key the server hands out for SOMEONE ELSE, unchecked.** The server
  holds every person's encryption public key. `add-member --from-server`
  takes it from the server, so the CLI shows its fingerprint and wraps to it
  only when the adder confirms it (`--fingerprint`, read out by the member) or
  this machine pinned it before (§6.1). The console has no add-member.
* **The person's OWN key, on a machine that never saw it.** The CLI pins the
  person's own encryption public key the first time a machine makes or sees
  it (trust on first use) and refuses a different answer by name from then
  on. A brand-new machine has nothing to compare with; the fingerprint is
  printed so the person can check it against another machine or the console.
* **Integrity of history.** A server could serve an old or rewritten history.
  Signed commits (`sshsig`, which gunnar already verifies) and the key ring
  continuity check (§5.2) narrow this; Level 1 does not close it alone.

---

## 2. The shape of it

```text
                 the user's side                          │   the server side (holds NO key)
                                                          │
   ~/.ssh/id_ed25519 ─┐                                   │   gunnar: refs, ACL, object ids,
   YubiKey (FIDO2) ───┼─▶ opens the person's ONE ◀────────┼── ciphertext objects, the key ring
   recovery code ─────┘   encryption key (age)            │   (wrapped to members' PUBLIC
            │                    │                        │   encryption keys), and each
            │                    ▼                        │   person's encryption key as
            │      gunnar CLI / git filter                │   ciphertext: one wrap per
            │      (unwrap, open, diff, merge, seal)      │   unlock method
            │                                             │         ▲
            └──▶ browser tab (console wasm): WebAuthn PRF │         │ ciphertext only
                 opens the encryption key, then the ring; │    gunnar-ui-server (relay)
                 open + diff + seal in wasm; tab memory ──┼─────────┘
                                                          │   twin, backups: the same ciphertext
```

* **One encryption key per person.** An age key pair, X25519 (or the hybrid
  `age1pq1…` for a quantum-safe person, §3.1). Its public half is what every
  repository the person belongs to is wrapped to. Its private half exists on
  the server only as ciphertext: one age file per unlock method.
* **Unlock methods.** An SSH key FILE (the CLI), a security key through
  WebAuthn PRF in the browser and FIDO2 hmac-secret in the CLI (one secret,
  §3.2), an `age1pq1…` key file, and a printed recovery code. Any one of them
  opens the person's key, and with it every ring.
* **The private halves never leave the user's side.** The CLI reads the SSH key
  from `~/.ssh` or asks the security key over USB. The browser asks the
  security key through WebAuthn. Neither sends a method's secret anywhere.
  The opened key and the rings live in the CLI's process or in the tab's wasm
  memory, and are never stored by gunnar.
* **gunnar-ui-server relays ciphertext and holds no keys.**
* **gunnar-server holds** the refs, the ACL, the object ids, the encrypted
  objects, the key ring, and each person's encryption key as ciphertext.
* **The twin and the backups** hold the same ciphertext.

**One crate, two targets.** `gunnar-crypt` is compiled native for the CLI and
wasm32 for the browser. Both sides run the same flow with the same code: open
the encryption key with an unlock method, fetch ciphertext, unwrap the key
ring, open and diff locally, seal before push. The same test vectors run on
both targets, and each side's output opens on the other
(`crates/gunnar-crypt/tests/vectors.rs`, `tests/userkey.rs`,
`tests/wasm-cross.sh`).

---

## 3. Keys

```text
unlock methods (each one an age file at the server, wrapping the SAME key)
   SSH key ─────────── age ssh-ed25519 / ssh-rsa stanza
   security key ────── PRF / hmac-secret → HKDF-SHA256 → X25519 → age X25519 stanza
   age1pq1… key ────── age mlkem768x25519 stanza
   recovery code ───── age scrypt stanza
        │
        ▼
the person's encryption key   AGE-SECRET-KEY-1…  (public: age1…)
        │ age X25519 stanza: one per member in .gunnar-crypt/keys
        ▼
key ring  (.gunnar-crypt/keys, armored age)
   ├── id key      32 bytes, made once, NEVER rotated      → keyed object ids (Level 2)
   ├── epoch 1     64 bytes, AES-256-SIV                    → content sealed before the 1st rotation
   ├── epoch 2     64 bytes                                  → …after it
   └── epoch n     (newest)                                  → everything sealed now
```

* **Member keys are people's encryption keys.** Wrapping needs only the PUBLIC
  half, which gunnar hands out (`Repos.RecipientKeys`, the forge's
  `gunnar-recipient-keys`), so adding a member needs nothing from them.
* **An SSH key is never a member key.** It is an unlock method. The members
  file refuses it by name, and the server never hands one out as a recipient.
  An `sk-` SSH key cannot decrypt at all; the security key itself is a method
  through its PRF (§3.2), not through SSH.
* **Adding or removing an unlock method touches no repository.** It changes
  one wrap of the person's key at the server. The ring of every repository
  stays byte for byte as it was.
* **ssh-agent cannot decrypt.** The agent protocol only signs. The CLI reads the
  key FILE and asks for its passphrase on the terminal.
* **The key ring** is one id key plus one data key per epoch. Rotation adds an
  epoch. Old epochs stay, so members can still open old content.
* **The id key** is separate from the content keys and never changes. Level 2
  names objects by `HMAC-SHA256(id key, object)`, so ids, and therefore refs,
  trees and commits, never depend on the content key (§6.3).
* **The person's key is one per person, never replaced.** gunnar refuses a
  second one: it would orphan every ring wrapped to the first. If a person's
  key itself is compromised, they need a new key, and every repository they
  belong to is re-wrapped and rotated (not built as one command).
* **An account recovery key** (§10) is simply one more recipient: an `age1…`
  key that the account admin keeps offline.

### 3.2 A security key as an unlock method

* The browser asks WebAuthn's `prf` extension of a credential, evaluated on one
  fixed input (`gunnar-crypt user-key unlock v1`). The CLI asks the same
  credential's FIDO2 `hmac-secret` with the salt WebAuthn derives for that
  input, `SHA-256("WebAuthn PRF" ‖ 0x00 ‖ input)`. Both get the same 32 bytes.
* HKDF-SHA256 (salt and label `gunnar-crypt security-key x25519 v1`, the
  credential id as context) makes the X25519 secret; its public half is an
  ordinary `age1…` recipient. So one credential always gives one recipient,
  and the wrap is age's own X25519 stanza. No primitive is ours.
* **User presence only.** An authenticator keeps one secret for "with user
  verification" and another for "without". The console asks with
  `userVerification: "discouraged"` and the CLI sends no PIN, so both get the
  "without" secret.
* **Bound to the console's name.** A credential belongs to a WebAuthn relying
  party (the console's host). The method records it, and the CLI names the
  same relying party, so one YubiKey works both ways.
* Vectors: `tests/vectors/check-security-key.py` (Python `cryptography`, no
  gunnar code) computes the derivation independently; `tests/userkey.rs`
  reproduces it native and wasm32. The CLI's FIDO2 client runs against a
  software authenticator (`gunnar-cli` `tests/fido2_soft.rs`), and the
  console's against Chrome's virtual authenticator with PRF.

### 3.1 The quantum-safe level

The content and the ids are already safe against a quantum computer:
AES-256-SIV with 256-bit keys and HMAC-SHA256 lose only half their strength to
Grover's algorithm. The weak part is the WRAP. An X25519 wrap falls to Shor's
algorithm, so anyone who copies `.gunnar-crypt/keys` today could open every
epoch in it later. This is "harvest now, decrypt later".

* **Classic** (the default) wraps the ring to members' X25519 encryption keys.
* **Quantum-safe** (`level quantum-safe` in `.gunnar-crypt/members`) wraps
  ONLY to age's standard hybrid post-quantum recipient (`age1pq1…`,
  c2sp.org/age `mlkem768x25519`). The secret is safe as long as either
  ML-KEM-768 or X25519 holds. No classical stanza may sit beside the hybrid
  ones; the server refuses one (§7.2).
* **A quantum-safe person** has a quantum-safe encryption key
  (`gunnar crypt key new --quantum-safe`). A person has ONE key, so they are a
  member of quantum-safe repositories only. age never mixes the two kinds in
  one ring.
* **A repository starts quantum-safe** (`gunnar crypt init --quantum-safe`).
  `upgrade-pq` is gone with the old format: in the one-key design a member's
  key does not change its kind.
* **The unlock methods' wraps** of a quantum-safe key are as strong as the
  method: an SSH key's wrap (X25519 or RSA) is not quantum-safe; an `age1pq1…`
  key file (`gunnar crypt keygen`, then `gunnar crypt key add-pq`) is.
* **What it costs** (measured before CRYPT-YUBI, the same stanza): about
  0.33 ms per hybrid stanza a member's unwrap tries; for 100 members the
  slowest unwrap is ~40 ms, for 500 ~0.3 s (§13).

## 4. Formats

### 4.1 The envelope: one encrypted file

New content is sealed as **envelope v2**, which is chunked. Envelope v1
(whole-file) is still read everywhere.

```text
v2 (2026-10-06, every new seal)
offset  0   8 bytes  magic        00 47 4E 43 52 59 50 54   ("\0GNCRYPT")
        8   1 byte   version      02
        9   4 bytes  epoch        big-endian
       13   4 bytes  chunk size   big-endian, 1 MiB (00 10 00 00)
       17  32 bytes  file nonce   HMAC-SHA256(nonce key, the whole plaintext)
       49   …        blocks       [16-byte SIV tag ‖ ciphertext] per chunk

block i = AES-256-SIV(epoch key, AD = header (49 bytes) ‖ i (u64, big-endian) ‖ last (1 byte), chunk i)
nonce key = HKDF-SHA256(IKM = epoch key, salt = none, info = "gunnar-crypt envelope v2 file nonce")

v1 (read only)
offset  0   8 bytes  magic   ("\0GNCRYPT")
        8   1 byte   version 01
        9   4 bytes  epoch   big-endian
       13  16 bytes  SIV tag
       29   …        ciphertext (AES-256-SIV over the whole file, AD = the 13 header bytes)
```

* **Fixed chunks, each one AES-256-SIV** (RFC 5297) under the epoch's 64-byte
  key. The plaintext is cut into 1 MiB chunks. The last chunk is shorter, and
  it is empty only when the whole file is empty (then it is the only block).
  Each block's associated data is the whole header, the block's index and a
  last-block flag. This is the STREAM construction (Hoang, Reyhanitabar,
  Rogaway, Vizár, CRYPTO 2015) with a deterministic AEAD. So:
  * blocks cannot be reordered (the index);
  * a dropped tail or an appended block does not open (the flag);
  * the header (version, epoch, chunk size, nonce) cannot be changed;
  * the filter seals and opens as it reads, holding one chunk, with no block
    count up front.
* **Why chunks.** AES-SIV is two-pass (a MAC over the whole input picks the
  IV, then CTR encrypts), so v1 needed the whole file in memory: several GB
  of RAM for a several-GB file, twice over in the filter. With v2 the filter
  holds a bounded amount, whatever the size (§5.1, measured in §13).
* **The file nonce closes the block-equality leak.** Per-block determinism
  alone would show two files (or two versions of one file) that share an
  aligned 1 MiB block in one epoch as an identical ciphertext block. An
  observer would see WHICH megabytes of a large file changed between commits.
  The nonce is a keyed MAC of the whole plaintext and sits in every block's
  associated data, so a difference anywhere in a file changes every block.
  What is left is what v1 already showed: two EQUAL files in one epoch are
  equal envelopes (§8). It costs one extra read of the plaintext before the
  first block. The filter gets that read for free: git hands over the whole
  file before it reads an answer, so the filter spools it anyway and MACs it
  on the way in. The nonce is keyed (HKDF from the epoch key), so it does not
  let anyone confirm a guessed file.
* **Deterministic**, on purpose, as in v1. The same content under the same
  key gives the same bytes. git's clean filter needs this, or `git status`
  would never settle. It also makes the blob id a KEYED id: it depends on the
  secret key, so nobody without the key can confirm a guessed file. The price
  is that equal files in the same repository and epoch have equal ids (§8).
* **Overhead.** v2 adds 49 bytes per file plus 16 bytes per started MiB:
  4 GiB costs 64 KiB + 49 bytes. v1 added 29 bytes.
* **The reader's bounds.** A chunk size of 0 or over 16 MiB is refused from
  the header. The streamed opener allocates 64 KiB first and grows only as
  far as the envelope proves to be long, so a short envelope that declares a
  large chunk costs its own length. The in-memory opener never allocates more
  than the envelope's length. Fuzz-style tests check this on native and
  wasm32 with a counting allocator: 3000 deterministic mutations (cuts, bit
  flips, inserted, appended and spliced bytes), a huge declared chunk size,
  and lengths that would overflow a block count. All are refused, with no
  panic (`tests/fuzz_open.rs`).
* **v1 → v2 without a repository-wide diff.** An unchanged file keeps the
  envelope it already is (§5.1), so a v1 file stays v1 until it changes.
* **The server's format check** (`is_envelope`) reads the first 17 bytes and
  the length. It accepts v1, and v2 whose length is a valid sequence of
  blocks.
* **Leading NUL**, so git treats every envelope as binary and never attempts a
  text diff or merge on ciphertext.
* **Vectors.** `tests/vectors/cases-v2.txt` (11 cases, 0 bytes to 3.5 MiB,
  TEST keys) was written by an independent implementation of this section
  (`tests/vectors/check-v2.py`: Python `cryptography`'s AESSIV, HKDF and
  HMAC, with no gunnar code). gunnar-crypt reproduces every vector on native
  and wasm32 (`tests/chunked.rs`, `tests/wasm-cross.sh`).

### 4.2 The key ring

```text
gunnar-crypt keyring v1
id-key <base64, 32 bytes>
epoch 1 <base64, 64 bytes>
epoch 2 <base64, 64 bytes>
```

* It is age-encrypted (armored) to every member's encryption key into
  `.gunnar-crypt/keys`: one `X25519` stanza per member in a classic
  repository and `mlkem768x25519` only in a quantum-safe one. age itself adds
  one random `…-grease` stanza to every file it writes.
* The first line is also a type tag: an unwrapped payload is taken ONLY if it
  parses as a key ring.

### 4.2.1 A person's encryption key at the server

```text
public  age1…                                  the person's encryption public key
method  <id> ssh           <SSH public key>    an armored age file: one ssh stanza
method  <id> security-key  <credential id>     … one X25519 stanza; + the relying party id
method  <id> age-pq        <age1pq1… key>      … one mlkem768x25519 stanza
method  <id> recovery-code (no ref)            … one scrypt stanza
```

Inside each wrap: `gunnar-crypt user-key v1` and the `AGE-SECRET-KEY-…`
line. Opening checks that the key inside is the one whose public half the
server publishes, so a wrap swapped ALONE on the server is refused by name.
A server that swaps both the public half and the wrap is caught by the
CLI's PIN (below, RA4-1), not by this check. The
server checks only format: an age file of at most 8 KiB with exactly one stanza
of the method's kind, at most 16 methods, never the last one removed
(`gunnar-core` `encryption_key.rs`).

### 4.3 The members file

`.gunnar-crypt/members`, plaintext, one encryption key per person:

```text
# gunnar-crypt members v1 — public keys only; the key ring in .gunnar-crypt/keys is wrapped to every key below.
alice age1…
bob age1…
recovery age1…
```

A quantum-safe repository adds one line and lists only `age1pq1…` keys. A
recovery key must then be a post-quantum one too (`age-keygen -pq`):

```text
# gunnar-crypt members v1 — public keys only; …
level quantum-safe
alice age1pq1…
bob age1pq1…
recovery age1pq1…
```

It is plaintext so that any member can re-wrap after an add or a removal. A
public key reveals nothing the server does not already hold. The file is also
what the server checks the key ring against (§7.2).

### 4.4 `.gitattributes`

```text
* filter=gunnar-crypt diff=gunnar-crypt merge=gunnar-crypt
.gitattributes !filter !diff !merge
.gunnar-crypt/** !filter !diff !merge
```

### 4.5 In a clone

`<git common dir>/gunnar-crypt/keyring` holds the unwrapped ring, mode 0600.
`keys-source` names the key file it came from (SHA-256), so a member never
re-wraps an older ring over a newer one.

---

## 5. Flows

### 5.1 The CLI (Level 1)

```text
gunnar crypt init [--member NAME] [--quantum-safe]   new ring, wrapped to your encryption key (made, wrapped
                                                 to your SSH key, when you have none); writes the three files; commits
gunnar crypt add-member NAME --from-server       one more wrap, to the encryption key gunnar holds for NAME;
                                                 no presence needed from them (or --key age1…)
gunnar crypt remove-member NAME                  drop their key AND rotate (new epoch for new content)
gunnar crypt rotate                              a new epoch
gunnar crypt unlock [-i KEY | --security-key | --recovery-code]
                                                 open your encryption key with one method, then the ring;
                                                 decrypt the work tree
gunnar crypt key status                          your encryption key and its unlock methods
gunnar crypt key new [--quantum-safe]            make it (the first time), wrapped to your SSH key
gunnar crypt key add-ssh|add-pq|add-recovery-code   one more method (open it first with another)
gunnar crypt key add-security-key --rp-id HOST   a FIDO2 key (two touches); it opens the key in the console too
gunnar crypt key remove ID                       delete one method's wrap (never the last); no repository changes
gunnar crypt keygen [-o FILE]                    an age1pq1… key file, made here (an unlock method: key add-pq)
gunnar crypt lock                                forget the ring; ciphertext back on disk
gunnar crypt status                              locked/unlocked, epochs, members and their key types
gunnar crypt diff FROM TO [-- PATH]              plaintext diff: the same code as the browser
```

git runs the rest:

* **clean** seals what is committed, and **smudge** opens what is checked out.
  This uses git's long-running filter protocol, with one process per git
  command.
* **Bounded memory.** git hands a filter the whole file before it reads an
  answer, so the filter SPOOLS it: in memory up to 4 MiB, then in an unnamed
  temporary file in the clone's own git directory (mode 0600, unlinked at
  once; the work tree next to it holds the same plaintext). Clean MACs the
  plaintext for the file nonce while it spools, then seals from the spool one
  1 MiB chunk at a time. Smudge verifies every block of a spooled envelope
  first, then opens it again to git. So a file that does not open is left
  sealed at any size, never half-written. textconv streams too. git ITSELF
  still holds a filtered file whole in its own memory (`convert.c`), and only
  LFS avoids that. Encrypted LFS is not designed. Measured on loki for a
  2 GiB file (§13): `git add` peaks at 2 GiB in git (was 6 GiB, in the
  filter), and `git checkout` at 4 GiB in git (as before). The filter itself
  holds 12–13 MiB.
* **textconv** opens for `git diff`, `log -p` and `blame`.
* **The merge driver** opens base, ours and theirs, merges the plaintext with
  `git merge-file`, and seals the result.
* `filter.gunnar-crypt.required = true`, so a filter that cannot seal (a
  locked clone) stops `git add`. Plaintext is never committed because the
  filter did not run.
* After a rotation, and across v1 → v2, an unchanged file still cleans to the
  blob it already is, so `git status` stays quiet and no repository-wide
  diff appears. A v2 envelope under the newest epoch needs no look: the seal
  is deterministic. For anything else (v1, an older epoch), the filter first
  compares sizes from one `git cat-file --batch-check` that lives as long as
  the filter (plaintext + 29 for v1, the v2 length for v2). Only on a match
  does it open the index's blob, streamed, and compare it with the spool.
* A file sealed under an epoch this clone does not hold is left sealed with a
  warning, and checkout does not fail. `gunnar crypt unlock` after pulling
  the newer `.gunnar-crypt/keys` opens it.
* **Pinned keys** (RA4-1). `$XDG_CONFIG_HOME/gunnar/encryption-pins` (else
  `~/.config/gunnar/…`, 0600) holds, per forge host, the person's own
  encryption public key (`self:<account>`) and each member key this machine
  was told to trust. The own key is pinned when `key new` or `init` makes it,
  or the first time a command sees it (said with its fingerprint); a forge
  answer that differs is refused by name and nothing is wrapped to it. A
  member's key from `--from-server` is taken only when pinned or confirmed
  with `--fingerprint SHA256:…`; a key handed over with `--key` is pinned as
  theirs.
* **The forge keeps the person's encryption key.** `init`, `unlock`, `key …`
  and `add-member --from-server` ask the forge `origin` names, over its SSH
  door, signed in by the same `ssh` git runs (`gunnar-user-key`,
  `gunnar-recipient-keys`). A clone with no SSH `origin` is told to add one.
  The answer is ciphertext and public keys only.
* **The CLI's security key** (`--security-key`) is a CTAP 2.1 client over Linux
  hidraw: PIN/UV auth protocol one, the hmac-secret extension, user presence
  only (`crates/gunnar-cli/src/fido2.rs`). RustCrypto `p256`, `aes`, `cbc`,
  `hmac` and `sha2` do the cryptography.

**Merges happen on a client.** The server only checks fast-forwards by object
id, as it always does.

### 5.2 Key ring continuity

When a clone unlocks a newer `.gunnar-crypt/keys`, the new ring must contain
every epoch it already holds with the same key, and the same id key.
Otherwise the new ring is refused.

This stops a **substituted ring**: someone who never held the keys, for
example an operator, makes a fresh ring, wraps it to the members' public keys
(no secret) and commits it. A member who accepted it would seal new content
under a key someone else knows. A fresh clone with no earlier ring cannot
detect this. Signed commits from known members are the anchor there.

**The ring must match its members.** Before a clone unwraps a ring, and before
the server takes one (§7.2), the stanzas in the ring's age header are checked
against `.gunnar-crypt/members`. No key is needed for this
(`gunnar_crypt::policy::check_ring`). The rules: one stanza per listed key; no
stanza type the level does not allow (an SSH stanza is refused in every
repository since CRYPT-YUBI); and nothing else except the one `…-grease`
stanza age adds. Without the check, a member who holds the file key could
append a stanza for someone the members file does not name, and nobody's
unwrap would notice. `X25519` and `mlkem768x25519` stanzas are anonymous by
design, so only the count and the type can be checked. The grease stanza is bounded at age's own sizes
(a type of at most 8 characters before `-grease`, at most 4 short arguments,
a body under 100 bytes). It still has room to hide a wrap that only a custom
age plugin would recognise, so the check narrows that path but does not close
it. The browser agent applies the same check when the page sends the members
file with the ring.

### 5.3 The browser

```text
1  the console shows an encrypted file: "Open with your security key or the Gunnar CLI"
2  the tab fetches the person's encryption key (public half + wraps) from the console's
   /api/me/encryption-key (Access.GetEncryptionKey, as the person)
3  WebAuthn get, allowCredentials = the person's security-key methods, extensions.prf.eval
   on gunnar's fixed input, userVerification "discouraged" → touch → the PRF output
4  in the tab's wasm: HKDF → the X25519 identity → open the wrap → the encryption key
   (checked against the published public half)
5  the tab fetches .gunnar-crypt/keys and .gunnar-crypt/members (ciphertext, plaintext),
   checks the ring against the members, unwraps it with the encryption key
6  it fetches the file (ciphertext), opens it, and renders it;
   a diff: it fetches both versions, opens both, and diffs in wasm
```

* **No helper and no CLI.** The browser opens the encryption key itself, with
  the security key, through WebAuthn. Nothing listens on 127.0.0.1 any more:
  `gunnar unlock` and its routes (`/v1/pair`, `/v1/unwrap`) are removed, and a
  test checks that no source in gunnar or gunnar-ui names them.
* **Enrolment, once per person** (Account → "Security keys for encryption"):
  the person touches the key, the tab derives its recipient, wraps the person's
  encryption key to it and uploads ONLY that wrap (and, the first time, the
  public half of a key made in the tab). It covers every repository at once.
  A second key, or a printed recovery code, is urged.
* **Refusals have names**: no PRF in this browser; this security key has no
  hmac-secret; this key is not one of your unlock methods; you are not a member
  of this repository; no security key enrolled yet.
* The encryption key and the rings live in the tab's memory only; a new tab
  touches the key again.
* The console server marks a file or a diff side whose bytes are an envelope
  as `sealed`. That is a format fact read from the first bytes; the server
  holds no key.

**The thin-UI rule.** The console otherwise only calls the server and
renders. Opening ciphertext in the tab is the one thing that must happen in
the browser by design, because the server must not hold the key. Every
decision stays on the server: who is a member is in the repository, and
whether the repository is encrypted is the server's flag.

### 5.4 No pre-made diffs

The server cannot diff ciphertext, and it keeps no pre-made diffs, which
would only cost space. The CLI diffs locally from the opened objects. The
browser fetches both encrypted versions, opens them and diffs in wasm, with
the same function the CLI uses.

*Later option:* an encrypted diff **cache**, written by a client and stored
beside the commit, if browser diffs prove slow on very large pull requests.
The cache would be sealed under the repository key like everything else.

---

## 6. Membership and rotation

### 6.1 Adding a member

Adding a member is one more wrap. Any member does it with the newcomer's
encryption PUBLIC key, and the newcomer does nothing. They pull and run
`gunnar crypt unlock` (or open the repository in the console with their
security key), and they can then read everything, old and new.

The key can come from the member (`--key age1…`) or from gunnar, which
holds every person's encryption public key (`--from-server`). gunnar is asked
at the forge `origin` pushes to, over its SSH door
(`gunnar-recipient-keys '<ns>/<repo>' '<member>'`, signed in by the same ssh
git runs): an installed estate exposes its forge's door and its console, and
binds the control plane to its WireGuard overlay. With `--at` the CLI asks a
control plane instead (`Repos.RecipientKeys`, `gunnar-cli`). Both doors keep
one rule:

* The adder confirms the key: the CLI prints its fingerprint and wraps to
  it only with `--fingerprint` (the member reads theirs out from
  `gunnar crypt key status` or the Account page) or when this machine pinned
  it before. The server's word alone is not taken (RA4-1).
* The caller needs Read on the repository.
* The named person must be able to read it too. gunnar hands out keys only
  for someone who could read the repository anyway; it is never a directory
  of everyone's keys.
* gunnar hands out the person's ONE encryption key, never an SSH key. A
  person without one is told how to make one (`gunnar crypt key new`, or a
  security key on the Account page).
* Only a key of the repository's level is taken: an `age1…` key for a classic
  repository, an `age1pq1…` key for a quantum-safe one.

### 6.2 Removing a member

`remove-member` drops their keys and rotates. A new epoch key encrypts from
then on, wrapped to the remaining members only. What does that mean?

* **Content written after the removal** is closed to them. They never get the
  new epoch.
* **What they already had, they keep.** They held the key and may keep a
  clone. Nothing can take that back, and the command says so.
* **Old ciphertext they did not copy.** They could open it only if they
  obtain it. Removing their grant on the server already stops them fetching
  it from gunnar. The residual risk is a leaked backup, or collusion with the
  operator. The command tells the remover to revoke the grant too.

### 6.3 Optional, future: re-encrypt old data under the new key

This would be an admin feature that rewrites every old object once under the
newest epoch. It closes the leaked-backup case above. It is labelled for what
it is: **the leaver keeps their own clone anyway.** It is not built. Its one
design constraint is built in already:

* **Object ids must not depend on the content key.** In Level 2 the id is
  `HMAC-SHA256(id key, object)`, using the fixed id key in every ring, while
  the stored bytes are sealed under an epoch key. Re-encryption then swaps
  stored bytes under unchanged ids, and refs, trees and commits stay stable.
* **In Level 1 it is a history rewrite.** The git blob id is the hash of the
  ciphertext, so re-encrypting changes every id. Level 1 does not offer it.
* When it is built, it must be **resumable**, with a cursor over object ids
  and each object compare-and-swapped from the old epoch to the new. It must
  also be **verifiable**: a member's client re-reads every object, opens it,
  recomputes its keyed id, and checks that the epoch is the newest.

---

### 6.4 Becoming quantum-safe

`gunnar crypt upgrade-pq` is gone (CRYPT-YUBI, 2026-10-26). A person has ONE
encryption key, of one kind, so a member's key does not change kind under a
repository. A quantum-safe repository starts quantum-safe
(`gunnar crypt init --quantum-safe`), with members whose encryption keys are
quantum-safe.

## 7. What the server sees

### 7.1 Level 1, today, with any gunnar and any git host

| visible to the server | sealed |
|---|---|
| file and directory names, the tree shape | file content |
| commit messages, authors, dates, the commit graph | |
| blob sizes (v2: +49 bytes and +16 per started MiB; v1: +29), file counts | |
| which files changed in which commit | |
| members' public encryption keys (`.gunnar-crypt/members`) | the key ring (`.gunnar-crypt/keys`) |
| each person's encryption public key and which unlock methods they hold (kind, label, an SSH public key, a credential id) | each person's encryption key (one wrap per method) |
| that two files in one epoch are equal (same id) | whether a KNOWN file is present: ids are keyed |
| the ring's stanza types and count | the file key and every epoch key |
| the repository's level (classic or quantum-safe) | |

What protects each part against a quantum computer:

| part | how it is protected | quantum-safe? |
|---|---|---|
| content (envelope) | AES-256-SIV, 256-bit key; v2's file nonce HMAC-SHA256 | yes |
| keyed ids | HMAC-SHA256, 256-bit id key | yes |
| key ring, classic level | X25519 (members' age1 encryption keys) | **no**: harvest now, decrypt later |
| a person's encryption key | as strong as each unlock method's wrap: SSH (X25519/RSA), security key (X25519), scrypt; an age1pq1 method is hybrid | **no**, unless every method is age1pq1 |
| key ring, quantum-safe level | HPKE with X-Wing (ML-KEM-768 + X25519) | yes |
| transport: SSH (gunnar's russh) | `mlkem768x25519-sha256` is negotiated (measured) | yes |
| transport: the console's TLS (rustls, ring provider) | X25519 only; `X25519MLKEM768` is refused (measured) | **no** |
| transport: WireGuard between boxes | no preshared key on any shipped path | **no** |

### 7.2 Level 2, first slice: the "encrypted" flag

* **The flag.** `gunnar control repo set-encrypted <ns>/<repo> on|off`
  (`Repos.SetEncrypted`), or at the console —
  `PATCH /api/repos/<ns>/<repo> {"encrypted": true}`, signed as the person —
  which is the door an installed estate offers (its control plane is bound to
  the WireGuard overlay). Only the account admin may set it, the same rule as
  public/private. Every change is audited.
* **The server refuses plaintext pushes.** The push gate walks every NEW tree
  and blob a push brings. Every blob must be an envelope. The exceptions are
  the root `.gitattributes`, `.gunnar-crypt/members`, and
  `.gunnar-crypt/keys`, which must be an age file. Symbolic links are
  refused, because git stores a link's target unencrypted. A blob no tree
  names must be an envelope or an age file. The refusal tells the user what
  to run (`gunnar crypt unlock`, then `git add --renormalize .`).
* **The ring against the members, and the level** (crypt-pq, 2026-10-06).
  When a push brings a new `.gunnar-crypt/keys` or `.gunnar-crypt/members`,
  the server runs the same check as the client (§5.2). It reads both files,
  taking the stored copy of the one the push did not change, and
  checks the ring's age header against the members file. That check needs no
  key. In a quantum-safe repository any stanza other than `mlkem768x25519` is
  refused: a classical ring, or a mixed one. In both levels the server refuses
  a stanza no member lists, a wrong count, and a ring without a members file.
  This runs in quarantine like the plaintext check, so nothing refused is
  stored or framed for the twin.
* **A bound on what anyone parses.** The client and the server read a key
  ring or a members file only up to 4 MiB; a larger one is refused by name.
  A quantum-safe member key costs ~2.1 KB of ring, so a quantum-safe
  repository holds at most ~2,000 member keys (a classic one ~28,000).
* **A format check, not an authenticity check.** The server has no key. It
  proves the client ran the filter, not that the envelope was sealed under
  the right key. A member who wants to leak can leak anyway.
* **Refused before anything is kept** (2026-10-06; it was the "honest limit"
  until then). On an encrypted repository the pushed pack is checked in
  QUARANTINE — the push's own spool, resolved against the repository for a
  thin pack's bases, put to the same walk the gate runs
  (`gunnar_policy::check_pushed`) — before it is framed in the transaction
  log or stored (`gunnar-server` `encrypted_quarantine`). A refused plaintext
  push leaves no object on the primary's disk and no frame in the log the
  twin replays; the client is told the gate's sentence. The gate also runs
  after the store, as the net. The client-side filter (`required = true`)
  is still the first line. (Until then a refused pack sat on the primary
  until `gunnar gc` AND was framed for the twin — measured on the estate by
  the kit's CR4.)

### 7.3 Level 2, full (design, not built)

* **Keyed object ids.** An object is named `HMAC-SHA256(id key, type ‖
  object)` instead of the SHA of its plaintext, and its bytes are sealed
  under the current epoch. The server negotiates fetches, checks
  fast-forwards and replicates by these ids without knowing what they hash.
* **Sealed trees and commit messages.** Names and messages join the content
  under the epoch key. The server then sees the commit graph (parent ids)
  and sizes only.
* **Lost on such repositories:** server-side diff, merge, blame, search and
  pull-request rendering (all client-side or in wasm); delta compression
  across objects (ciphertext does not delta); and the server's own hash
  verification (integrity comes from signed commits and the keyed ids, which
  the client verifies on open).
* **Search** is a client-built encrypted index, or none.

---

## 8. What still leaks

Say it in the product too.

* **Sizes.** Every blob is its plaintext size plus 49 bytes and 16 bytes per
  started MiB (v2), or plus 29 bytes (v1). So the plaintext size is exact.
* **Equal blocks: not shown.** Sealing 1 MiB blocks one by one would show an
  aligned block that two files, or two versions of one file, share in an
  epoch. v2 binds every block to a keyed MAC of the whole file (§4.1), so it
  does not. Only whole-file equality shows, as in v1. This was a decision, not
  a default: the alternative (no nonce) saves one pass over the plaintext, and
  the filter does not need that pass saved.
* **Timing.** When pushes happen, and how often.
* **Counts.** How many files, commits and branches there are.
* **The graph.** Parents, merges and the branch structure.
* **Level 1 also leaks** file names, paths, commit messages, authors and
  dates; which files changed together; that two files are identical (equal
  envelopes within an epoch); and the members' public keys.
* **Level 2 (full)** hides names and messages. Sizes, timing, counts and the
  graph stay visible.
* **Submodules** (gitlinks) are commit ids of another repository, in the
  clear.
* **Classic rings** (§3.1). A ring wrapped to X25519 encryption keys can be
  opened by whoever holds a copy once quantum computers break X25519. That
  includes every older `.gunnar-crypt/keys` in history. So can a person's
  encryption key, through any classical unlock method's wrap (§7.1).
* **Which unlock methods a person holds.** The server sees the kinds, the
  labels, the SSH public keys and the credential ids of each person's methods.
* **The transport** (§7.1 table). The console's TLS and the WireGuard links
  between boxes are not post-quantum. They carry ciphertext and metadata, not
  keys, so for an encrypted repository a harvested session yields what the
  server sees anyway.

---

## 9. The trust gap: the server delivers the wasm

**The wasm still comes from the server.** The console's code, the wasm that
asks the security key and opens the encryption key in the tab, is sent by the
server the person is protecting themselves from. A compromised server could
send wasm that keeps the PRF output, the opened encryption key or a key ring
and sends it somewhere else. The security key cannot tell good wasm from bad:
it answers any page on the console's own origin. Removing the local helper
did not change this gap, and it does not close it. What reduces it:

* **A release-signed, pinned console bundle.** The release publishes the
  bundle's hash and signs it with the release key. The console page loads the
  wasm and JS with Subresource Integrity hashes. A user who cares pins the
  hash (or a browser extension does), and `gunnar` can verify a served
  console against the signed release manifest. *To build: SRI in the
  console's index.html, a signed manifest in the release, and a
  `gunnar console verify <url>`. Not built.*
* **The CLI is the highest-assurance path.** The user's own binary, the
  user's own key (SSH key file or the security key over USB), no code from the
  server. For the most sensitive repositories, say: use the CLI.
* **The browser cannot pin for real.** The console keeps the person's own
  encryption public key in the tab's local storage the first time it sees it
  and refuses a different answer later, by name. But the code doing that check
  is the server's own wasm: a hostile server can send wasm that skips it, or
  clear the storage. So the browser's pin catches a swapped key only from an
  honest console; the CLI's pin does not depend on the server's code.
* **What a bad page gets is the person's ONE key**, which opens every ring
  they are a member of, not only the repository on screen. That is the price
  of "enrol once, every repository". Someone who fears it keeps the security
  key out of the console and uses the CLI.

## 10. Recovery and escrow

If every member loses every key, the content is gone. The server cannot help,
by design. The options, in the order to recommend them:

1. **More than one unlock method per person.** A second security key, an SSH
   key on another device, or a printed recovery code: each is one more wrap of
   the same encryption key, and the console urges at least two. This is the
   cheapest insurance.
2. **An account recovery key.** The account admin makes an age key
   (`age-keygen`) and keeps it offline: printed, in a safe, or on a hardware
   key. They add its public half as the member `recovery`. It is one more
   recipient of every key ring in the account.
   * The account admin decides this, per account. It is visible in every
     members file, which is honest.
   * Its holder can read everything, so it is a member in all but name.
   * Account policy can require it on every encrypted repository. That is a
     server-side rule the admin sets, checked at push like the plaintext
     check. It is not built yet.
3. **No escrow.** This is allowed and is the default. The console says what
   it means.

**The operator never holds a recovery key.** An operator-held key would undo
the point of the feature.

---

## 11. Optional "server may decrypt for the session" mode (design, not built)

Some teams want server-side pull-request rendering and search more than they
fear their operator. The account admin may, per repository, allow a member to
release the key ring to gunnar for one session:

* The ring is sent from the member's client on the member's action,
  held in server memory only for that session, and dropped at its end or
  after a short ttl. It is never written to disk.
* Every release is audited: who released it, which repository, which
  session, and when.
* The repository shows the label **"server may decrypt during sessions —
  weaker"** wherever it appears.
* The default stays **off**. With it on, the operator can read during those
  sessions, and the label says exactly that.

---

## 12. Choices

* **age 0.11.5** for the wrap (str4d/rage, MIT OR Apache-2.0, maintained,
  with a published spec and SSH recipients). Version 0.12 pulls `ml-kem 0.2`,
  whose `kem 0.3.0-pre` cannot share a lock with russh's `ml-kem 0.3`. Move
  to 0.12+ when age does.
* **The quantum-safe wrap is age's own hybrid stanza, glued on age 0.11.5**
  (crypt-pq, 2026-10-06). The options, checked in this order:
  1. **A newer age.** None exists. age 0.12.1 (2026-07) has no `age1pq`
     recipient or identity, only the encryption-only `tag`/`tagpq` types, and
     it still pulls `ml-kem 0.2`. The native `pq` type is rage PR #632, still
     open.
  2. **Aligning russh or ml-kem.** Every russh from 0.62.7 on requires
     `ml-kem ^0.3`, and `ml-kem 0.2` pins `kem =0.3.0-pre.0`. Only an older
     russh would align, and that is not an option.
  3. **A `[patch]`.** It would point at unreleased forks of age, hpke and
     x-wing (#632 itself patches the last two). A published gunnar crate
     cannot carry it.
  4. **Chosen:** `gunnar_crypt::pq` implements the standard `mlkem768x25519`
     stanza, `age1pq1…` recipient and `AGE-SECRET-KEY-PQ-1…` identity on age
     0.11.5's public `Recipient`/`Identity` traits. The stanza is labelled
     `postquantum`, so age itself refuses to mix it with a classical
     recipient. No primitive is ours. HPKE (RFC 9180) and the X-Wing KEM
     (MLKEM768-X25519) come from the released `hpke` 0.14.1, with HKDF-SHA256
     and ChaCha20Poly1305, which are age's parameters. The spec's
     low-order-share rule comes from `x-wing` 0.1.1's
     `DecapsulationKeyRejectNonContrib`. Both use the `ml-kem 0.3` that russh
     already brings, so the lock gained six crates. All 19 C2SP CCTV `hybrid`
     vectors pass (`tests/pq.rs`). Go age v1.3.2 opens what gunnar wraps and
     the other way round (measured on oden).
  **Replace it with age's native `pq` module when age releases one (rage PR
  #632).** The format does not change: the CCTV vectors and the Go interop
  test prove that the swap is equal.
* **aes-siv 0.8** (RustCrypto) for the envelope: the standard deterministic
  AEAD.
* **Envelope v2 is STREAM over AES-SIV, built only from reviewed parts**
  (crypt-chunked, 2026-10-06): RustCrypto `aes-siv` per block, `hmac` 0.12
  and `hkdf` 0.12 for the file nonce, on the `sha2` 0.10 the crate already
  links (both were already in gunnar's lock; gunnar-ui's gains `hkdf` 0.12.4,
  and the console's wasm grows by 4 KB, 3 KB gzipped). Rejected options:
  * age's STREAM (ChaCha20-Poly1305 under a random file key) is not
    deterministic, and git's clean filter must be.
  * A block count in the header would need the whole file before the first
    byte and would add nothing to the last-block flag.
  * A per-file key would add a key schedule, not a property; the nonce in the
    associated data already separates files.
* **Not git-crypt.** It has no rotation, no per-member SSH keys and no
  browser.
* **No local agent** (CRYPT-YUBI). The `gunnar unlock` helper on 127.0.0.1 was
  removed: the browser opens the person's key with the security key itself.
* **The security key through its PRF, not a new scheme.** The PRF output goes
  only through HKDF-SHA256 into an X25519 identity, and the wrap is age's own
  X25519 stanza. Rejected: an AEAD wrap of each ring under the PRF output (a
  symmetric secret: every rotation by another member would need every
  member's touch); a per-repository salt (the owner wanted one enrolment per
  person); `sk-` SSH keys (they only sign).
* **The CLI's FIDO2 client is message glue over hidraw.** No libfido2, no
  hidapi: CTAPHID frames, a small CBOR subset, protocol one, and RustCrypto for
  every primitive.

## 13. Status (2026-10-26)

| piece | state |
|---|---|
| `gunnar-crypt`: envelope, key ring with id key, members, policy, wrap, diff | built; the same vectors pass native and wasm32; the cross-check passes |
| envelope v2: 1 MiB chunks, STREAM over AES-SIV, the file nonce (§4.1) | built (crypt-chunked, 2026-10-06): 11 vectors from an independent implementation, reproduced native and wasm32; reorder, cut, append, splice and every flipped bit refused; fuzz-style no-crash suite with a counting allocator; v1 still opens; the server's gate takes v2 as sealed |
| the filters stream in bounded memory; v1 → v2 without a diff | built; a 4 GiB file through clean and smudge: the filter's peak 13 MiB and 15 MiB (release, loki); an unchanged v1 file stays v1; tested in gunnar-cli `crypt_stream` |
| the console opens v2 | built (gunnar-ui `sealed`): the same vectors; the wasm grows by 4 KB |
| one encryption key per person, opened by any unlock method (SSH key, security key, age1pq1 key, recovery code) | built (CRYPT-YUBI, 2026-10-26): `gunnar-crypt` `userkey`; vectors from an independent implementation, native and wasm32 |
| people's encryption keys at the server (`Access.*EncryptionKey`, `*UnlockMethod`, the forge door `gunnar-user-key`) | built; format checks, self-scope only; tested |
| `gunnar crypt` init/add/remove/rotate/unlock/lock/status/diff/key, filter, textconv, merge | built; tested against a real gunnar server, including an SSH-only person doing the whole flow |
| `add-member --from-server` (`Repos.RecipientKeys`): the member's encryption key | built; tested (access rule, no key yet) |
| `gunnar crypt unlock --security-key` (FIDO2 hmac-secret over hidraw) | built; tested against a software authenticator; not yet against a physical YubiKey |
| `gunnar unlock` agent | REMOVED (CRYPT-YUBI, 2026-10-26); a test checks no source serves its routes |
| repository flag "encrypted", account admin only, plaintext push refused | built (Level 2, first slice) |
| console: an encrypted file and an encrypted diff opened in the tab with the security key (WebAuthn PRF) | gunnar-ui, CRYPT-YUBI; the browser rig uses Chrome's virtual authenticator with PRF |
| a console switch for the "encrypted" flag | built (gunnar-ui, round 5.3.1): the repository's Settings, "Turn encryption on/off…" (`PATCH /api/repos/<ns>/<repo>`, signed as the account admin); New repository has an encrypted choice; `EncryptedChanged` in the feed |
| keyed ids, sealed trees/messages, gunnar-native store (Level 2 full) | design (§7.3) |
| signed, pinned console bundle | design (§9) |
| "server may decrypt for the session" | design (§11) |
| re-encrypt old data | future (§6.3); the id-key constraint is built in |
| quantum-safe level: a quantum-safe person's key, `init --quantum-safe` | built (§3.1); tested against a real gunnar server; `upgrade-pq` removed (§6.4) |
| ring checked against members (client and server, both levels); classical/mixed ring refused in quarantine at quantum-safe | built (§5.2, §7.2); tested |
| hybrid stanza against the C2SP CCTV vectors and Go age v1.3.2 | 19/19 vectors; interop both ways |
| cost of the quantum-safe level (`examples/pq_bench.rs`) | measured on loki, single thread: keygen 0.06 ms; 100 members: unwrap as the last member 38.6 ms (classic 1.4), wrap 13.2 ms (11.3); 500 members: 291 ms (32), wrap 66 ms (57), ring 1030 KB (73) |
| never a crash (`tests/pq_stress.rs`) | 500 members; 4,000 seeded mutations all refused, none malleable; 8 MiB stanzas and >4 MiB files refused before parsing |
| console TLS `X25519MLKEM768`, WireGuard PSK | not built; transport, reported in §7.1 |
| recovery-key policy per account | design (§10) |

**Measured** (2026-10-06, loki, 12 cores, release builds with the same flags,
client features only; v1 = gunnar `c8f3e1c`, v2 = `feat/crypt-chunked`). The
long-running filter was driven over git's protocol by `crypt_stream`'s
`bench_clean_smudge`. Small files ran 2000 (1 KiB) or 64 (1 MiB) per filter
process; large files ran one per process. Peak RSS is the filter's VmHWM.
loki was shared at the time (load 15–23 from other jobs), so the small-file
numbers are noisy.

| file | op | v1 MB/s | v1 peak | v2 MB/s | v2 peak |
|---|---|---|---|---|---|
| 1 KiB × 2000 | clean | 0.885 ms/file | 9 MiB | 0.592 ms/file | 8 MiB |
| 1 KiB × 2000 | smudge | 0.018 ms/file | 9 MiB | 0.018 ms/file | 8 MiB |
| 1 MiB × 64 | clean | 203 | 12 MiB | 212 | 10 MiB |
| 1 MiB × 64 | smudge | 569 | 11 MiB | 588 | 10 MiB |
| 100 MiB | clean | 266 | 308 MiB | 369 | 13 MiB |
| 100 MiB | smudge | 367 | 209 MiB | 318 | 15 MiB |
| 2 GiB | clean | 263 | 6,152 MiB | 362 | 13 MiB |
| 2 GiB | smudge | 365 | 4,105 MiB | 314 | 15 MiB |

* v2 smudge of a spooled file (over 4 MiB) is about 13 % slower than v1. It
  verifies every block before git sees a byte, so a file that does not open
  is left sealed rather than half-written. That is the trade, and it was made
  on purpose.
* Through git itself (`git add` / `git checkout` of 2 GiB, `/usr/bin/time`
  on git and on the filter):
  * v1: add 6,150 MiB (the filter), checkout 4,102 MiB.
  * v2: add 2,052 MiB in git with the filter at 12 MiB; checkout 4,101 MiB in
    git with the filter at 13 MiB.
  * git holds a filtered file whole (§5.1); LFS is the way past that.
* Overhead: a 2 GiB file's envelope is 32,817 bytes longer than the file
  (v1: 29).