gunnar-crypt 1.5.1

Encrypted repositories: the content envelope, the per-repo key ring, its members, the server's plaintext check, each person's one encryption key and its unlock methods, and the ring's wrap to members' encryption keys
Documentation
//! The repository's data keys: one 64-byte AES-256-SIV key per EPOCH.
//!
//! Epoch 1 is made by `gunnar crypt init`. Every rotation (`gunnar crypt
//! rotate`, and `remove-member`, which rotates) appends the next epoch. New
//! content is sealed under the newest epoch; old epochs stay in the ring so
//! members can still open old content. A removed member keeps whatever ring
//! they already had (nothing can take that back) but is never given the new
//! one, so content written after the rotation is closed to them.
//!
//! The ring also holds the repository's ID KEY: 32 bytes, made once at init
//! and NEVER rotated. Level 2 (gunnar-native storage) names every object by a
//! keyed id, HMAC-SHA256(id key, object), and seals its bytes under the epoch
//! key. Because the id does not depend on the content key, a future
//! "re-encrypt old data under the new key" can rewrite every stored object
//! while refs, commits and trees keep their ids. Level 1 (plain git) does not
//! use it yet; it is in every ring from the first one so no ring ever has to
//! be re-made to get it.
//!
//! The text form (the plaintext inside the age-wrapped `.gunnar-crypt/keys`,
//! and the clone's local `.git/gunnar-crypt/keyring`):
//!
//! ```text
//! gunnar-crypt keyring v1
//! id-key <base64 of 32 bytes>
//! epoch 1 <base64 of 64 bytes>
//! epoch 2 <base64 of 64 bytes>
//! ```
//!
//! The first line doubles as a type tag: an unwrapped payload is taken ONLY
//! when it parses as a key ring, so no other age file sealed to a user's key
//! is ever read as one.

use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;

/// A public name for a file's exact bytes (SHA-256, hex): the CLI records
/// which `.gunnar-crypt/keys` its local ring came from, so it never re-wraps
/// an older ring over a newer one.
pub fn file_id(bytes: &[u8]) -> String {
    Sha256::digest(bytes)
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect()
}

/// The first line of every key ring.
pub const HEADER: &str = "gunnar-crypt keyring v1";
/// Bytes in one data key (AES-256-SIV takes two 256-bit keys).
pub const KEY_LEN: usize = 64;
/// Bytes in the id key.
pub const ID_KEY_LEN: usize = 32;

/// The repository's fixed id key (see the module docs). Zeroed on drop.
#[derive(Clone, PartialEq, Eq)]
pub struct IdKey(Zeroizing<[u8; ID_KEY_LEN]>);

impl IdKey {
    /// From raw bytes.
    pub fn from_bytes(bytes: [u8; ID_KEY_LEN]) -> IdKey {
        IdKey(Zeroizing::new(bytes))
    }

    /// A fresh random id key.
    #[cfg(feature = "keygen")]
    pub fn generate() -> IdKey {
        let mut bytes = Zeroizing::new([0u8; ID_KEY_LEN]);
        getrandom::getrandom(&mut bytes[..]).expect("the OS random source is available");
        IdKey(bytes)
    }

    /// The raw key.
    pub fn bytes(&self) -> &[u8; ID_KEY_LEN] {
        &self.0
    }
}

impl std::fmt::Debug for IdKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "IdKey(…)")
    }
}

/// One epoch's data key. Zeroed on drop; `Debug` never prints the key.
#[derive(Clone)]
pub struct DataKey {
    epoch: u32,
    bytes: Zeroizing<[u8; KEY_LEN]>,
}

impl DataKey {
    /// A key from raw bytes. Epochs start at 1.
    pub fn from_bytes(epoch: u32, bytes: [u8; KEY_LEN]) -> DataKey {
        DataKey {
            epoch,
            bytes: Zeroizing::new(bytes),
        }
    }

    /// A fresh random key (the OS random source).
    #[cfg(feature = "keygen")]
    pub fn generate(epoch: u32) -> DataKey {
        let mut bytes = Zeroizing::new([0u8; KEY_LEN]);
        getrandom::getrandom(&mut bytes[..]).expect("the OS random source is available");
        DataKey { epoch, bytes }
    }

    /// The epoch this key belongs to.
    pub fn epoch(&self) -> u32 {
        self.epoch
    }

    /// The raw key.
    pub fn bytes(&self) -> &[u8; KEY_LEN] {
        &self.bytes
    }

    /// A short public fingerprint of the key (16 hex digits of a domain-separated
    /// SHA-256), safe to print: it names a key without revealing it.
    pub fn id(&self) -> String {
        let mut h = Sha256::new();
        h.update(b"gunnar-crypt key id v1\0");
        h.update(&self.bytes[..]);
        let d = h.finalize();
        d[..8].iter().map(|b| format!("{b:02x}")).collect()
    }
}

impl std::fmt::Debug for DataKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "DataKey {{ epoch: {}, id: {} }}", self.epoch, self.id())
    }
}

impl PartialEq for DataKey {
    fn eq(&self, other: &Self) -> bool {
        // Not a secret-dependent early exit worth worrying about here: both
        // sides are keys the caller already holds.
        self.epoch == other.epoch && self.bytes[..] == other.bytes[..]
    }
}
impl Eq for DataKey {}

/// Why a key ring was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyringError {
    /// The payload does not start with [`HEADER`] — not a key ring at all.
    NotAKeyring,
    /// A line that is not `epoch <n> <base64>`.
    BadLine(usize),
    /// Epochs must be 1, 2, 3, … with no gap and no repeat.
    BadEpochs,
    /// A ring with no key.
    Empty,
    /// The `id-key` line is missing or malformed.
    BadIdKey,
}

impl std::fmt::Display for KeyringError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            KeyringError::NotAKeyring => write!(f, "not a gunnar-crypt key ring"),
            KeyringError::BadLine(n) => write!(f, "key ring line {n} is malformed"),
            KeyringError::BadEpochs => {
                write!(f, "key ring epochs must run 1, 2, 3, … without gaps")
            }
            KeyringError::Empty => write!(f, "the key ring holds no key"),
            KeyringError::BadIdKey => {
                write!(f, "the key ring's id-key line is missing or malformed")
            }
        }
    }
}
impl std::error::Error for KeyringError {}

/// A newer key ring that does not extend the one a clone already holds.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContinuityError {
    /// The newer ring lacks an epoch the older ring has.
    Dropped(u32),
    /// The newer ring has a DIFFERENT key for an epoch the older ring has —
    /// the signature of a substituted ring (someone who never held the real
    /// keys made a fresh ring and wrapped it to the members).
    Replaced(u32),
    /// The newer ring has a different id key. The id key never changes.
    IdKeyChanged,
}

impl std::fmt::Display for ContinuityError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ContinuityError::Dropped(e) => write!(
                f,
                "the new key ring drops epoch {e}, which this clone holds — refused \
                 (a key ring only ever grows; this one may have been substituted)"
            ),
            ContinuityError::Replaced(e) => write!(
                f,
                "the new key ring has a different key for epoch {e} than this clone \
                 holds — refused (a substituted key ring would let its maker read \
                 everything written after it)"
            ),
            ContinuityError::IdKeyChanged => write!(
                f,
                "the new key ring has a different id key — refused (the id key is made \
                 once and never changes; this ring may have been substituted)"
            ),
        }
    }
}
impl std::error::Error for ContinuityError {}

/// All of a repository's data keys, epochs 1..=n.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Keyring {
    id_key: IdKey,
    keys: Vec<DataKey>,
}

impl Keyring {
    /// A ring holding the id key and one data key, which must be epoch 1.
    pub fn new(id_key: IdKey, first: DataKey) -> Keyring {
        assert_eq!(first.epoch, 1, "a key ring starts at epoch 1");
        Keyring {
            id_key,
            keys: vec![first],
        }
    }

    /// A fresh ring: a random id key and a random epoch-1 key.
    #[cfg(feature = "keygen")]
    pub fn generate() -> Keyring {
        Keyring::new(IdKey::generate(), DataKey::generate(1))
    }

    /// The fixed id key.
    pub fn id_key(&self) -> &IdKey {
        &self.id_key
    }

    /// The same ring cut back to epochs `1..=epoch`: what a member removed
    /// after `epoch` still holds. For tests and for explaining a removal.
    pub fn truncated(&self, epoch: u32) -> Keyring {
        Keyring {
            id_key: self.id_key.clone(),
            keys: self.keys[..(epoch as usize).min(self.keys.len()).max(1)].to_vec(),
        }
    }

    /// Append a fresh random key as the next epoch and return its epoch.
    #[cfg(feature = "keygen")]
    pub fn rotate(&mut self) -> u32 {
        let next = self.newest().epoch + 1;
        self.keys.push(DataKey::generate(next));
        next
    }

    /// The key for `epoch`.
    pub fn get(&self, epoch: u32) -> Option<&DataKey> {
        if epoch == 0 {
            return None;
        }
        self.keys.get(epoch as usize - 1)
    }

    /// The key new content is sealed under.
    pub fn newest(&self) -> &DataKey {
        self.keys.last().expect("a key ring is never empty")
    }

    /// Every key, oldest first.
    pub fn keys(&self) -> &[DataKey] {
        &self.keys
    }

    /// The text form. Zeroed on drop.
    pub fn to_text(&self) -> Zeroizing<String> {
        let mut s = String::with_capacity(32 + self.keys.len() * 100);
        s.push_str(HEADER);
        s.push('\n');
        s.push_str(&format!("id-key {}\n", B64.encode(&self.id_key.0[..])));
        for k in &self.keys {
            s.push_str(&format!("epoch {} {}\n", k.epoch, B64.encode(&k.bytes[..])));
        }
        Zeroizing::new(s)
    }

    /// Parse the text form.
    pub fn parse(text: &str) -> Result<Keyring, KeyringError> {
        let mut lines = text.lines();
        if lines.next().map(str::trim_end) != Some(HEADER) {
            return Err(KeyringError::NotAKeyring);
        }
        let mut keys = Vec::new();
        let mut id_key = None;
        for (i, line) in lines.enumerate() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            if let Some(b) = line.strip_prefix("id-key ") {
                if id_key.is_some() {
                    return Err(KeyringError::BadIdKey);
                }
                let raw = Zeroizing::new(B64.decode(b.trim()).map_err(|_| KeyringError::BadIdKey)?);
                let bytes: [u8; ID_KEY_LEN] =
                    raw[..].try_into().map_err(|_| KeyringError::BadIdKey)?;
                id_key = Some(IdKey::from_bytes(bytes));
                continue;
            }
            let mut parts = line.split_ascii_whitespace();
            let (Some("epoch"), Some(n), Some(b), None) =
                (parts.next(), parts.next(), parts.next(), parts.next())
            else {
                return Err(KeyringError::BadLine(i + 2));
            };
            let epoch: u32 = n.parse().map_err(|_| KeyringError::BadLine(i + 2))?;
            let raw = Zeroizing::new(B64.decode(b).map_err(|_| KeyringError::BadLine(i + 2))?);
            let bytes: [u8; KEY_LEN] = raw[..]
                .try_into()
                .map_err(|_| KeyringError::BadLine(i + 2))?;
            if epoch as usize != keys.len() + 1 {
                return Err(KeyringError::BadEpochs);
            }
            keys.push(DataKey::from_bytes(epoch, bytes));
        }
        if keys.is_empty() {
            return Err(KeyringError::Empty);
        }
        let id_key = id_key.ok_or(KeyringError::BadIdKey)?;
        Ok(Keyring { id_key, keys })
    }

    /// Does `newer` extend this ring — every epoch this ring holds, with the
    /// same key? A clone that already holds a ring refuses a newer one that
    /// does not: a ring only ever grows.
    pub fn check_continuity(&self, newer: &Keyring) -> Result<(), ContinuityError> {
        if newer.id_key != self.id_key {
            return Err(ContinuityError::IdKeyChanged);
        }
        for k in &self.keys {
            match newer.get(k.epoch) {
                None => return Err(ContinuityError::Dropped(k.epoch)),
                Some(n) if n != k => return Err(ContinuityError::Replaced(k.epoch)),
                Some(_) => {}
            }
        }
        Ok(())
    }
}