gunnar_crypt/lib.rs
1//! **Encrypted repositories**: the operator of a gunnar server cannot read a
2//! repository's content; only its members can, each with their own
3//! encryption key, opened by their SSH key or their security key.
4//!
5//! The design, the threat model and what still leaks are in
6//! `.nornir/encrypted-repos.md`. This crate is the part every door shares:
7//!
8//! * [`envelope`] — the format of one encrypted file (blob): a 13-byte header
9//! naming the key epoch, then AES-256-SIV over the content. Deterministic on
10//! purpose: the same content under the same key gives the same blob, so git's
11//! clean filter is stable and `git status` stays quiet, and the blob id is a
12//! KEYED id (it depends on the secret key), so nobody without the key can
13//! confirm that a known file is in the repository.
14//! * [`keyring`] — the repository's data keys, one per epoch. A rotation adds
15//! an epoch; old epochs stay so members can still read old content.
16//! * [`members`] — the plaintext list of members' PUBLIC keys
17//! (`.gunnar-crypt/members`). The key ring is wrapped to every key in it.
18//! * [`policy`] — what a server checks on a push to an encrypted repository:
19//! every blob is an envelope, except the three files the scheme itself needs.
20//! It reads formats, never keys.
21//! * `userkey` (feature `user-key`) — the USER's encryption key: one key
22//! pair per user (age X25519, or the hybrid `age1pq1…` for quantum-safe
23//! repositories), its private half stored on the server only as age files,
24//! one per unlock method: an SSH key file (CLI), a security key's WebAuthn
25//! PRF / FIDO2 hmac-secret output through HKDF-SHA256 (browser and CLI), an
26//! `age1pq1…` key file, or a printed recovery code (age's scrypt).
27//! * `wrap` (feature `user-key`) — the age wrap of the key ring to every
28//! member's user public key. A repository never names an SSH key or a
29//! security key; adding or removing an unlock method touches no repository.
30//! Feature `wrap` adds the SSH unlock method and the quantum-safe user key.
31//! * `pq` (feature `wrap`) — that hybrid recipient and its identity, until an
32//! age release carries its own.
33//!
34//! * [`diff`] (feature `diff`) — line diffs of decrypted content. The server
35//! cannot diff ciphertext and keeps no pre-made diffs; the client does it.
36//!
37//! **One crate, two targets.** The CLI and the browser console run this same
38//! code, native and wasm32: fetch ciphertext → open the user key with an
39//! unlock method → unwrap the key ring → open and diff locally → seal before
40//! push. The CLI opens the user key with the SSH key file in `~/.ssh` (or a
41//! security key over FIDO2 hmac-secret); the browser opens it with a security
42//! key (WebAuthn PRF), in the tab, with no helper. `tests/vectors.rs` and
43//! `tests/userkey.rs` run the same vectors on both targets.
44//!
45//! Who links what: gunnar-server takes `default-features = false` (policy and
46//! format only; no cipher, no key), the browser console takes `cipher`,
47//! `diff` and `user-key`, and the CLI takes everything.
48
49#![forbid(unsafe_code)]
50#![doc(html_logo_url = "https://gunnar.rs/download/gunnar.webp", html_favicon_url = "https://gunnar.rs/download/favicon.svg")]
51
52/// zeroize, for callers holding key material.
53#[cfg(feature = "cipher")]
54pub use zeroize;
55
56#[cfg(feature = "diff")]
57pub mod diff;
58pub mod door;
59pub mod envelope;
60#[cfg(feature = "cipher")]
61pub mod keyring;
62pub mod members;
63pub mod policy;
64#[cfg(feature = "wrap")]
65pub mod pq;
66#[cfg(feature = "user-key")]
67pub mod userkey;
68#[cfg(feature = "user-key")]
69pub mod wrap;
70
71/// `N` bytes from the OS random source (keys, recovery codes).
72#[cfg(feature = "keygen")]
73pub fn random_bytes<const N: usize>() -> [u8; N] {
74 let mut b = [0u8; N];
75 getrandom::getrandom(&mut b).expect("the OS random source is available");
76 b
77}
78
79/// Open a blob as text: an envelope is opened with `ring`; anything else is
80/// returned as it is (history from before `gunnar crypt init` is plaintext).
81#[cfg(feature = "cipher")]
82pub fn open_blob(ring: &keyring::Keyring, blob: &[u8]) -> Result<Vec<u8>, envelope::OpenError> {
83 if envelope::has_magic(blob) {
84 envelope::open(ring, blob)
85 } else {
86 Ok(blob.to_vec())
87 }
88}
89
90/// The diff between two stored blobs of one path (`None` = the path does not
91/// exist on that side), opened with `ring`. What `gunnar crypt diff` prints
92/// and what the console shows for an encrypted repository.
93#[cfg(all(feature = "cipher", feature = "diff"))]
94pub fn diff_blobs(
95 ring: &keyring::Keyring,
96 old: Option<&[u8]>,
97 new: Option<&[u8]>,
98) -> Result<diff::FileDiff, envelope::OpenError> {
99 let old = old
100 .map(|b| open_blob(ring, b))
101 .transpose()?
102 .unwrap_or_default();
103 let new = new
104 .map(|b| open_blob(ring, b))
105 .transpose()?
106 .unwrap_or_default();
107 Ok(diff::hunks(
108 &String::from_utf8_lossy(&old),
109 &String::from_utf8_lossy(&new),
110 ))
111}
112
113/// Where the scheme's own files live in a repository.
114pub mod paths {
115 /// The directory of the scheme's files.
116 pub const DIR: &str = ".gunnar-crypt";
117 /// The key ring, age-encrypted to every member key (armored).
118 pub const KEYS: &str = ".gunnar-crypt/keys";
119 /// The members and their PUBLIC keys, plaintext.
120 pub const MEMBERS: &str = ".gunnar-crypt/members";
121 /// git's attributes file: it names the filter, so it must stay plaintext.
122 pub const GITATTRIBUTES: &str = ".gitattributes";
123}
124
125/// The name of the git filter, diff and merge drivers.
126pub const DRIVER: &str = "gunnar-crypt";
127
128/// The `.gitattributes` lines `gunnar crypt init` writes.
129///
130/// Everything goes through the filter except the scheme's own files, which
131/// must stay readable before a clone is unlocked.
132pub const GITATTRIBUTES_LINES: &[&str] = &[
133 "* filter=gunnar-crypt diff=gunnar-crypt merge=gunnar-crypt",
134 ".gitattributes !filter !diff !merge",
135 ".gunnar-crypt/** !filter !diff !merge",
136];