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 new repository is quantum-safe (§3.1) unless asked otherwise: its key ring is 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-servertakes 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
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: the hybrid
age1pq1…(quantum-safe, the default for a new key), or X25519age1…for a person who chose a classic key (§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
~/.sshor 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
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 (per-wrap salt) → XChaCha20-Poly1305 (wrap v2)
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'sgunnar-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
prfextension of a credential, evaluated on one fixed input (gunnar-crypt user-key unlock v1). The CLI asks the same credential's FIDO2hmac-secretwith the salt WebAuthn derives for that input,SHA-256("WebAuthn PRF" ‖ 0x00 ‖ input). Both get the same 32 bytes. - The wrap v2 is symmetric (CRYPT-PQ-SK, owner 2026-10-30;
gunnar_crypt::skwrap): a fresh 32-byte salt per wrap,key = HKDF-SHA256(salt, PRF output, info "gunnar-crypt user-key wrap v2"), then XChaCha20-Poly1305 with a fresh 24-byte nonce over the person's key, the associated data"gunnar-crypt user-key wrap v2" ‖ 0x00 ‖ credential id. One line of text:gunnar-crypt user-key wrap v2 <salt> <nonce> <sealed>(base64 without padding). There is no public-key step, so a quantum computer gains only Grover's square root against a 256-bit key: the wrap is quantum-safe, and the CLI and the console mark it so. No primitive is ours (RustCryptohkdfandchacha20poly1305). - v1 still opens, and is re-wrapped. Before CRYPT-PQ-SK a security key's wrap was age's X25519 stanza to a recipient derived from the PRF output (HKDF-SHA256, salt and label
gunnar-crypt security-key x25519 v1, the credential id as context). The CLI and the console still open it. On the next unlock with it they wrap the key v2 under the same touch and add that method; gunnar replaces the v1 wrap of that credential with it (its label, relying party and date kept) and never takes a v1 over a v2. The server checks a v2 wrap's shape only: it holds no key. - 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(Pythoncryptography, no gunnar code) computes the derivation independently;tests/userkey.rsreproduces it native and wasm32;tests/security_key_v2.rsholds the v2 wrap (round trip, a wrong PRF output, another credential, a flipped byte, v1 → v2) on both targets. The CLI's FIDO2 client runs against a software authenticator (gunnar-clitests/fido2_soft.rs, including the v1 → v2 re-wrap), and the console's against Chrome's virtual authenticator with PRF (gunnar-ui uitest: enrolment is v2, and a v1 method is re-wrapped to v2 on unlock).
3.1 The quantum-safe level (the default)
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".
Quantum-safe is the default (owner, 2026-10-30, CRYPT-PQ-DEFAULT). A new repository and a new person's key are quantum-safe unless asked otherwise. The default lives in one place, gunnar_crypt::members::Level::NEW, which the CLI and the console both read.
-
Quantum-safe (
level quantum-safein.gunnar-crypt/members) wraps ONLY to age's standard hybrid post-quantum recipient (age1pq1…, c2sp.org/agemlkem768x25519). 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).gunnar crypt initstarts a repository at this level. -
Classic (
gunnar crypt init --level classic, or the "classic" choice on the console's New repository) wraps the ring to members' X25519 encryption keys. It is the visible opt-out for members whose one key is classic. A members file without alevelline is classic, as every file was before the line existed, so an existing repository keeps its level and nothing is migrated. -
A person's key is quantum-safe when it is made (
gunnar crypt key new,gunnar crypt initfor a person without one, or the first security key on the console's Account page), unless they ask for a classic one (gunnar crypt key new --classic, or "Make a classic key instead" in the console). A person has ONE key, so the kind is chosen once: a quantum-safe person is a member of quantum-safe repositories only, a classic one of classic repositories only. age never mixes the two kinds in one ring.gunnar crypt initwith a classic key is refused by name, with--level classicin the sentence, and writes nothing. -
A classic repository becomes quantum-safe with
gunnar crypt level quantum-safeonce every member has a quantum-safe key (§6.4). The level only goes up. -
Every unlock method opens a quantum-safe key. Each method's OWN wrap is as strong as the method, and the CLI (
gunnar crypt key status) and the console say which:
| unlock method | its wrap of the key | quantum-safe? |
age1pq1… key file (gunnar crypt keygen, then gunnar crypt key add-pq) |
hybrid mlkem768x25519 stanza |
yes |
| printed recovery code | age's scrypt stanza (a symmetric secret) | yes |
| security key (WebAuthn PRF / FIDO2 hmac-secret through HKDF) | wrap v2: XChaCha20-Poly1305 under HKDF of the PRF output (§3.2) | yes (a v1 X25519 wrap: no, re-wrapped on the next unlock) |
| SSH key file | age ssh-ed25519 (X25519) or ssh-rsa (RSA-OAEP) stanza |
no |
-
The SSH key's wrap of a quantum-safe key is classical, and gunnar says so.
gunnar crypt key new,gunnar crypt init(when it makes the key) andgunnar crypt key add-sshprint it plainly (gunnar_crypt::userkey::SSH_WRAP_IS_CLASSICAL); the console marks the method "wrap: not quantum-safe" with the same sentence. Nothing falls back to a classic key: the key and the repositories stay quantum-safe, and only that one way to open the key is not. For a key that stays quantum-safe at the server too, a person adds a security key, a key file or a recovery code and removes the SSH method (gunnar crypt key remove <id>). -
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.
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 statuswould 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: Pythoncryptography'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
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: oneX25519stanza per member in a classic repository andmlkem768x25519only in a quantum-safe one. age itself adds one random…-greasestanza 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
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> a wrap v2 line (§3.2; a v1 left from before: 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:
# 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 new repository is quantum-safe: it has one more line and lists only age1pq1… keys. A recovery key must then be a post-quantum one too (age-keygen -pq). A file without the line (above) is classic:
# 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
* 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)
gunnar crypt init [--member NAME] [--level classic] new ring, wrapped to your encryption key (made, wrapped
to your SSH key, when you have none); quantum-safe unless
--level classic; writes the three files; commits
gunnar crypt level [quantum-safe] the level; or re-wrap a classic repository to every member's
[--key NAME=age1pq1…] [--from-server] quantum-safe key and rotate (§6.4)
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 [--classic] make it (the first time), wrapped to your SSH key;
quantum-safe unless --classic
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 addpeaks at 2 GiB in git (was 6 GiB, in the filter), andgit checkoutat 4 GiB in git (as before). The filter itself holds 12–13 MiB. - textconv opens for
git diff,log -pandblame. - 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) stopsgit 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 statusstays 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 onegit cat-file --batch-checkthat 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 unlockafter pulling the newer.gunnar-crypt/keysopens 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 whenkey neworinitmakes 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-serveris taken only when pinned or confirmed with--fingerprint SHA256:…; a key handed over with--keyis pinned as theirs. - The forge keeps the person's encryption key.
init,unlock,key …andadd-member --from-serverask the forgeoriginnames, over its SSH door, signed in by the samesshgit runs (gunnar-user-key,gunnar-recipient-keys). A clone with no SSHoriginis 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). RustCryptop256,aes,cbc,hmacandsha2do 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
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 wrap v2's key → XChaCha20-Poly1305 → the encryption key
(checked against the published public half); a v1 wrap opens through X25519 and the
tab adds its v2 re-wrap, which replaces it
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 unlockand 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 fromgunnar crypt key statusor 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, anage1pq1…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
A new repository starts quantum-safe (§3.1). A classic one is upgraded with gunnar crypt level quantum-safe (CRYPT-PQ-DEFAULT, 2026-10-30):
gunnar crypt level says the level
gunnar crypt level quantum-safe --key alice=age1pq1… --key bob=bob-pq.pub
gunnar crypt level quantum-safe --from-server [--fingerprint bob=SHA256:…]
- Every member needs a quantum-safe key. Each one comes from
--key NAME=…(a key line or a file), or with--from-serverfrom the key Gunnar holds for them (pinned, or confirmed with--fingerprint NAME=…, asadd-membertakes it, RA4-1). Every member without one is named, and nothing changes. A key handed over with--keyreplaces the pin of that member's classic key on this machine, and the command says so. - The ring is rotated and re-wrapped only to those keys, with the same id key and every old epoch. Content committed from then on is sealed under a key that was never wrapped classically. Old content stays under the old epochs, and every copy of the classic ring in history stays as weak as it was: the upgrade protects what comes after it.
- The level only goes up.
gunnar crypt level classicin a quantum-safe repository is refused, and so is the push (§7.2). - The limit of the one-key design. A person has ONE encryption key, and it never changes kind. A member of a classic repository has a classic key, so the upgrade completes only when each member hands over a quantum-safe key (
gunnar crypt keygen) with--key, and that member then opens the ring with that key file, not with their one key at the forge. Replacing a person's classic key with a quantum-safe one is not built (§3). For a team that wants quantum safety, the path is a new repository, which is quantum-safe by default.
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 (§3.1): SSH (X25519/RSA) is classical; a security key's wrap v2, scrypt and an age1pq1 key file are not | no while an SSH method (or a security key's v1 wrap) is held |
| 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, thengit add --renormalize .). - The ring against the members, and the level (crypt-pq, 2026-10-06). When a push brings a new
.gunnar-crypt/keysor.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 thanmlkem768x25519is 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-serverencrypted_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 untilgunnar gcAND 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/keysin 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
gunnarcan 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 agunnar 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:
-
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.
-
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 memberrecovery. 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.
-
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, whosekem 0.3.0-precannot share a lock with russh'sml-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:
- A newer age. None exists. age 0.12.1 (2026-07) has no
age1pqrecipient or identity, only the encryption-onlytag/tagpqtypes, and it still pullsml-kem 0.2. The nativepqtype is rage PR #632, still open. - Aligning russh or ml-kem. Every russh from 0.62.7 on requires
ml-kem ^0.3, andml-kem 0.2pinskem =0.3.0-pre.0. Only an older russh would align, and that is not an option. - 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. - Chosen:
gunnar_crypt::pqimplements the standardmlkem768x25519stanza,age1pq1…recipient andAGE-SECRET-KEY-PQ-1…identity on age 0.11.5's publicRecipient/Identitytraits. The stanza is labelledpostquantum, 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 releasedhpke0.14.1, with HKDF-SHA256 and ChaCha20Poly1305, which are age's parameters. The spec's low-order-share rule comes fromx-wing0.1.1'sDecapsulationKeyRejectNonContrib. Both use theml-kem 0.3that russh already brings, so the lock gained six crates. All 19 C2SP CCTVhybridvectors 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 nativepqmodule 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.
- A newer age. None exists. age 0.12.1 (2026-07) has no
-
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-sivper block,hmac0.12 andhkdf0.12 for the file nonce, on thesha20.10 the crate already links (both were already in gunnar's lock; gunnar-ui's gainshkdf0.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 unlockhelper 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, wrapped symmetrically (CRYPT-PQ-SK, owner's option 2). The PRF output is a symmetric secret, so the wrap of the person's key is HKDF-SHA256 and XChaCha20-Poly1305, with no X25519 step that a quantum computer could break (v1, age's X25519 stanza to a derived recipient, is re-wrapped on the next unlock). XChaCha20-Poly1305 rather than AES-256-GCM: its 24-byte random nonce needs no counter, and the crate was already in the lock (hpke). 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-30)
| 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 | built (§3.1); tested against a real gunnar server |
| a security key's wrap v2: symmetric (HKDF-SHA256 over the PRF output, per-wrap salt, XChaCha20-Poly1305), quantum-safe; a v1 (X25519) wrap re-wrapped to v2 on the next unlock | built (CRYPT-PQ-SK, 2026-10-30): gunnar-crypt tests/security_key_v2.rs (native and wasm32 build), gunnar-core (v2 replaces v1, never the other way), gunnar-cli fido2_soft, gunnar-ui sealed and the uitest browser rows (Chrome's virtual authenticator with PRF) |
quantum-safe is the DEFAULT: crypt init and crypt key new are quantum-safe unless --level classic / --classic; the console makes a quantum-safe key and offers classic as a choice; every method opens it and says its wrap's strength |
built (CRYPT-PQ-DEFAULT, 2026-10-30); gunnar-crypt tests/default_level.rs, gunnar-cli encrypted_repo (init, classic opt-out, key new), gunnar-ui sealed (the console's key, security key, recovery code) |
gunnar crypt level quantum-safe: a classic repository re-wrapped and rotated once every member has a quantum-safe key |
built (§6.4, CRYPT-PQ-DEFAULT); tested against a real gunnar server (members without one named, nothing changed; the server takes classic → quantum-safe; the level only goes up) |
| 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 checkoutof 2 GiB,/usr/bin/timeon 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).