crates/gunnar-crypt/src/keyring.rs
//! 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(())
}
}