Skip to main content

gunnar_crypt/
lib.rs

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