Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

.nornir/encrypted-repos.md

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:

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:


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 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

3.2 A security key as an unlock method

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.

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

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)

4.2 The key ring

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

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:

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

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:

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?

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:


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:…]

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

7.3 Level 2, full (design, not built)


8. What still leaks

Say it in the product too.


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:

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:


12. Choices

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