Skip to main content

gunnar_crypt/
keyring.rs

1//! The repository's data keys: one 64-byte AES-256-SIV key per EPOCH.
2//!
3//! Epoch 1 is made by `gunnar crypt init`. Every rotation (`gunnar crypt
4//! rotate`, and `remove-member`, which rotates) appends the next epoch. New
5//! content is sealed under the newest epoch; old epochs stay in the ring so
6//! members can still open old content. A removed member keeps whatever ring
7//! they already had (nothing can take that back) but is never given the new
8//! one, so content written after the rotation is closed to them.
9//!
10//! The ring also holds the repository's ID KEY: 32 bytes, made once at init
11//! and NEVER rotated. Level 2 (gunnar-native storage) names every object by a
12//! keyed id, HMAC-SHA256(id key, object), and seals its bytes under the epoch
13//! key. Because the id does not depend on the content key, a future
14//! "re-encrypt old data under the new key" can rewrite every stored object
15//! while refs, commits and trees keep their ids. Level 1 (plain git) does not
16//! use it yet; it is in every ring from the first one so no ring ever has to
17//! be re-made to get it.
18//!
19//! The text form (the plaintext inside the age-wrapped `.gunnar-crypt/keys`,
20//! and the clone's local `.git/gunnar-crypt/keyring`):
21//!
22//! ```text
23//! gunnar-crypt keyring v1
24//! id-key <base64 of 32 bytes>
25//! epoch 1 <base64 of 64 bytes>
26//! epoch 2 <base64 of 64 bytes>
27//! ```
28//!
29//! The first line doubles as a type tag: an unwrapped payload is taken ONLY
30//! when it parses as a key ring, so no other age file sealed to a user's key
31//! is ever read as one.
32
33use base64::engine::general_purpose::STANDARD as B64;
34use base64::Engine as _;
35use sha2::{Digest, Sha256};
36use zeroize::Zeroizing;
37
38/// A public name for a file's exact bytes (SHA-256, hex): the CLI records
39/// which `.gunnar-crypt/keys` its local ring came from, so it never re-wraps
40/// an older ring over a newer one.
41pub fn file_id(bytes: &[u8]) -> String {
42    Sha256::digest(bytes)
43        .iter()
44        .map(|b| format!("{b:02x}"))
45        .collect()
46}
47
48/// The first line of every key ring.
49pub const HEADER: &str = "gunnar-crypt keyring v1";
50/// Bytes in one data key (AES-256-SIV takes two 256-bit keys).
51pub const KEY_LEN: usize = 64;
52/// Bytes in the id key.
53pub const ID_KEY_LEN: usize = 32;
54
55/// The repository's fixed id key (see the module docs). Zeroed on drop.
56#[derive(Clone, PartialEq, Eq)]
57pub struct IdKey(Zeroizing<[u8; ID_KEY_LEN]>);
58
59impl IdKey {
60    /// From raw bytes.
61    pub fn from_bytes(bytes: [u8; ID_KEY_LEN]) -> IdKey {
62        IdKey(Zeroizing::new(bytes))
63    }
64
65    /// A fresh random id key.
66    #[cfg(feature = "keygen")]
67    pub fn generate() -> IdKey {
68        let mut bytes = Zeroizing::new([0u8; ID_KEY_LEN]);
69        getrandom::getrandom(&mut bytes[..]).expect("the OS random source is available");
70        IdKey(bytes)
71    }
72
73    /// The raw key.
74    pub fn bytes(&self) -> &[u8; ID_KEY_LEN] {
75        &self.0
76    }
77}
78
79impl std::fmt::Debug for IdKey {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        write!(f, "IdKey(…)")
82    }
83}
84
85/// One epoch's data key. Zeroed on drop; `Debug` never prints the key.
86#[derive(Clone)]
87pub struct DataKey {
88    epoch: u32,
89    bytes: Zeroizing<[u8; KEY_LEN]>,
90}
91
92impl DataKey {
93    /// A key from raw bytes. Epochs start at 1.
94    pub fn from_bytes(epoch: u32, bytes: [u8; KEY_LEN]) -> DataKey {
95        DataKey {
96            epoch,
97            bytes: Zeroizing::new(bytes),
98        }
99    }
100
101    /// A fresh random key (the OS random source).
102    #[cfg(feature = "keygen")]
103    pub fn generate(epoch: u32) -> DataKey {
104        let mut bytes = Zeroizing::new([0u8; KEY_LEN]);
105        getrandom::getrandom(&mut bytes[..]).expect("the OS random source is available");
106        DataKey { epoch, bytes }
107    }
108
109    /// The epoch this key belongs to.
110    pub fn epoch(&self) -> u32 {
111        self.epoch
112    }
113
114    /// The raw key.
115    pub fn bytes(&self) -> &[u8; KEY_LEN] {
116        &self.bytes
117    }
118
119    /// A short public fingerprint of the key (16 hex digits of a domain-separated
120    /// SHA-256), safe to print: it names a key without revealing it.
121    pub fn id(&self) -> String {
122        let mut h = Sha256::new();
123        h.update(b"gunnar-crypt key id v1\0");
124        h.update(&self.bytes[..]);
125        let d = h.finalize();
126        d[..8].iter().map(|b| format!("{b:02x}")).collect()
127    }
128}
129
130impl std::fmt::Debug for DataKey {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        write!(f, "DataKey {{ epoch: {}, id: {} }}", self.epoch, self.id())
133    }
134}
135
136impl PartialEq for DataKey {
137    fn eq(&self, other: &Self) -> bool {
138        // Not a secret-dependent early exit worth worrying about here: both
139        // sides are keys the caller already holds.
140        self.epoch == other.epoch && self.bytes[..] == other.bytes[..]
141    }
142}
143impl Eq for DataKey {}
144
145/// Why a key ring was refused.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum KeyringError {
148    /// The payload does not start with [`HEADER`] — not a key ring at all.
149    NotAKeyring,
150    /// A line that is not `epoch <n> <base64>`.
151    BadLine(usize),
152    /// Epochs must be 1, 2, 3, … with no gap and no repeat.
153    BadEpochs,
154    /// A ring with no key.
155    Empty,
156    /// The `id-key` line is missing or malformed.
157    BadIdKey,
158}
159
160impl std::fmt::Display for KeyringError {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        match self {
163            KeyringError::NotAKeyring => write!(f, "not a gunnar-crypt key ring"),
164            KeyringError::BadLine(n) => write!(f, "key ring line {n} is malformed"),
165            KeyringError::BadEpochs => {
166                write!(f, "key ring epochs must run 1, 2, 3, … without gaps")
167            }
168            KeyringError::Empty => write!(f, "the key ring holds no key"),
169            KeyringError::BadIdKey => {
170                write!(f, "the key ring's id-key line is missing or malformed")
171            }
172        }
173    }
174}
175impl std::error::Error for KeyringError {}
176
177/// A newer key ring that does not extend the one a clone already holds.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum ContinuityError {
180    /// The newer ring lacks an epoch the older ring has.
181    Dropped(u32),
182    /// The newer ring has a DIFFERENT key for an epoch the older ring has —
183    /// the signature of a substituted ring (someone who never held the real
184    /// keys made a fresh ring and wrapped it to the members).
185    Replaced(u32),
186    /// The newer ring has a different id key. The id key never changes.
187    IdKeyChanged,
188}
189
190impl std::fmt::Display for ContinuityError {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        match self {
193            ContinuityError::Dropped(e) => write!(
194                f,
195                "the new key ring drops epoch {e}, which this clone holds — refused \
196                 (a key ring only ever grows; this one may have been substituted)"
197            ),
198            ContinuityError::Replaced(e) => write!(
199                f,
200                "the new key ring has a different key for epoch {e} than this clone \
201                 holds — refused (a substituted key ring would let its maker read \
202                 everything written after it)"
203            ),
204            ContinuityError::IdKeyChanged => write!(
205                f,
206                "the new key ring has a different id key — refused (the id key is made \
207                 once and never changes; this ring may have been substituted)"
208            ),
209        }
210    }
211}
212impl std::error::Error for ContinuityError {}
213
214/// All of a repository's data keys, epochs 1..=n.
215#[derive(Clone, Debug, PartialEq, Eq)]
216pub struct Keyring {
217    id_key: IdKey,
218    keys: Vec<DataKey>,
219}
220
221impl Keyring {
222    /// A ring holding the id key and one data key, which must be epoch 1.
223    pub fn new(id_key: IdKey, first: DataKey) -> Keyring {
224        assert_eq!(first.epoch, 1, "a key ring starts at epoch 1");
225        Keyring {
226            id_key,
227            keys: vec![first],
228        }
229    }
230
231    /// A fresh ring: a random id key and a random epoch-1 key.
232    #[cfg(feature = "keygen")]
233    pub fn generate() -> Keyring {
234        Keyring::new(IdKey::generate(), DataKey::generate(1))
235    }
236
237    /// The fixed id key.
238    pub fn id_key(&self) -> &IdKey {
239        &self.id_key
240    }
241
242    /// The same ring cut back to epochs `1..=epoch`: what a member removed
243    /// after `epoch` still holds. For tests and for explaining a removal.
244    pub fn truncated(&self, epoch: u32) -> Keyring {
245        Keyring {
246            id_key: self.id_key.clone(),
247            keys: self.keys[..(epoch as usize).min(self.keys.len()).max(1)].to_vec(),
248        }
249    }
250
251    /// Append a fresh random key as the next epoch and return its epoch.
252    #[cfg(feature = "keygen")]
253    pub fn rotate(&mut self) -> u32 {
254        let next = self.newest().epoch + 1;
255        self.keys.push(DataKey::generate(next));
256        next
257    }
258
259    /// The key for `epoch`.
260    pub fn get(&self, epoch: u32) -> Option<&DataKey> {
261        if epoch == 0 {
262            return None;
263        }
264        self.keys.get(epoch as usize - 1)
265    }
266
267    /// The key new content is sealed under.
268    pub fn newest(&self) -> &DataKey {
269        self.keys.last().expect("a key ring is never empty")
270    }
271
272    /// Every key, oldest first.
273    pub fn keys(&self) -> &[DataKey] {
274        &self.keys
275    }
276
277    /// The text form. Zeroed on drop.
278    pub fn to_text(&self) -> Zeroizing<String> {
279        let mut s = String::with_capacity(32 + self.keys.len() * 100);
280        s.push_str(HEADER);
281        s.push('\n');
282        s.push_str(&format!("id-key {}\n", B64.encode(&self.id_key.0[..])));
283        for k in &self.keys {
284            s.push_str(&format!("epoch {} {}\n", k.epoch, B64.encode(&k.bytes[..])));
285        }
286        Zeroizing::new(s)
287    }
288
289    /// Parse the text form.
290    pub fn parse(text: &str) -> Result<Keyring, KeyringError> {
291        let mut lines = text.lines();
292        if lines.next().map(str::trim_end) != Some(HEADER) {
293            return Err(KeyringError::NotAKeyring);
294        }
295        let mut keys = Vec::new();
296        let mut id_key = None;
297        for (i, line) in lines.enumerate() {
298            let line = line.trim();
299            if line.is_empty() || line.starts_with('#') {
300                continue;
301            }
302            if let Some(b) = line.strip_prefix("id-key ") {
303                if id_key.is_some() {
304                    return Err(KeyringError::BadIdKey);
305                }
306                let raw = Zeroizing::new(B64.decode(b.trim()).map_err(|_| KeyringError::BadIdKey)?);
307                let bytes: [u8; ID_KEY_LEN] =
308                    raw[..].try_into().map_err(|_| KeyringError::BadIdKey)?;
309                id_key = Some(IdKey::from_bytes(bytes));
310                continue;
311            }
312            let mut parts = line.split_ascii_whitespace();
313            let (Some("epoch"), Some(n), Some(b), None) =
314                (parts.next(), parts.next(), parts.next(), parts.next())
315            else {
316                return Err(KeyringError::BadLine(i + 2));
317            };
318            let epoch: u32 = n.parse().map_err(|_| KeyringError::BadLine(i + 2))?;
319            let raw = Zeroizing::new(B64.decode(b).map_err(|_| KeyringError::BadLine(i + 2))?);
320            let bytes: [u8; KEY_LEN] = raw[..]
321                .try_into()
322                .map_err(|_| KeyringError::BadLine(i + 2))?;
323            if epoch as usize != keys.len() + 1 {
324                return Err(KeyringError::BadEpochs);
325            }
326            keys.push(DataKey::from_bytes(epoch, bytes));
327        }
328        if keys.is_empty() {
329            return Err(KeyringError::Empty);
330        }
331        let id_key = id_key.ok_or(KeyringError::BadIdKey)?;
332        Ok(Keyring { id_key, keys })
333    }
334
335    /// Does `newer` extend this ring — every epoch this ring holds, with the
336    /// same key? A clone that already holds a ring refuses a newer one that
337    /// does not: a ring only ever grows.
338    pub fn check_continuity(&self, newer: &Keyring) -> Result<(), ContinuityError> {
339        if newer.id_key != self.id_key {
340            return Err(ContinuityError::IdKeyChanged);
341        }
342        for k in &self.keys {
343            match newer.get(k.epoch) {
344                None => return Err(ContinuityError::Dropped(k.epoch)),
345                Some(n) if n != k => return Err(ContinuityError::Replaced(k.epoch)),
346                Some(_) => {}
347            }
348        }
349        Ok(())
350    }
351}