Skip to main content

git_xcrypt/crypto/
key.rs

1//! The repository key and everything derived from it.
2//!
3//! The key file holds a 32-byte **master key**, never a cipher key. Different
4//! ciphers take keys of different lengths — AES-256-SIV wants 64 bytes,
5//! AES-256-GCM-SIV and XChaCha20 want 32 — and the key file is as frozen as the
6//! data format once it sits in a user's backups. Deriving per suite is what
7//! keeps a future suite from stranding it.
8
9use hkdf::Hkdf;
10use sha2::Sha256;
11use zeroize::{Zeroize, ZeroizeOnDrop};
12
13use crate::crypto::format::{KEY_ID_LEN, SUITE_AES_256_SIV};
14use crate::{Error, Result};
15
16/// Length of the master key stored in the key file.
17pub const MASTER_KEY_LEN: usize = 32;
18
19/// Length of the key AES-256-SIV expects: an S2V half and a CTR half.
20pub const SIV_KEY_LEN: usize = 64;
21
22/// Domain separation for the key fingerprint carried by every file.
23const INFO_KEY_ID: &[u8] = b"git-xcrypt key-id v1";
24
25/// Domain separation for the AES-256-SIV working key.
26const INFO_SUITE_AES_256_SIV: &[u8] = b"git-xcrypt suite 0x01 aes-256-siv";
27
28/// The repository key.
29///
30/// Deliberately has no `Debug`, `Display` or `Clone`: the only ways out are
31/// [`MasterKey::expose_bytes`], whose name is meant to be uncomfortable at a
32/// call site, and the derivations below.
33#[derive(Zeroize, ZeroizeOnDrop)]
34pub struct MasterKey([u8; MASTER_KEY_LEN]);
35
36impl MasterKey {
37    /// Draws a fresh key from the operating system's entropy source.
38    ///
39    /// # Errors
40    ///
41    /// [`Error::Entropy`] when the platform refuses to provide randomness.
42    /// Falling back to anything weaker would be worse than failing.
43    pub fn generate() -> Result<Self> {
44        let mut bytes = [0u8; MASTER_KEY_LEN];
45        let drawn = getrandom::fill(&mut bytes).map_err(|err| Error::Entropy(err.to_string()));
46        let key = drawn.map(|()| Self(bytes));
47        // The staging array is a second copy of the key; the one inside
48        // `MasterKey` is the only one allowed to outlive this call.
49        bytes.zeroize();
50        key
51    }
52
53    /// Wraps key material that came from a key file.
54    #[must_use]
55    pub fn from_bytes(bytes: [u8; MASTER_KEY_LEN]) -> Self {
56        Self(bytes)
57    }
58
59    /// The raw key material.
60    ///
61    /// Named to make every call site read like the disclosure it is. Only the
62    /// key file writer and `export-key` have any business calling it.
63    #[must_use]
64    pub fn expose_bytes(&self) -> &[u8; MASTER_KEY_LEN] {
65        &self.0
66    }
67
68    /// The fingerprint stored in every encrypted file's header.
69    ///
70    /// Identifies the *key*, not the suite, so it survives a future change of
71    /// cipher and keeps `unlock` and `export-key` working across
72    /// one.
73    ///
74    /// # Panics
75    ///
76    /// Never in practice: [`KEY_ID_LEN`] is eight bytes against HKDF's ceiling
77    /// of 8160, so the expansion cannot fail. Handing back half-derived material
78    /// instead would put a wrong `key_id` into a file header for good.
79    #[must_use]
80    pub fn key_id(&self) -> [u8; KEY_ID_LEN] {
81        let mut key_id = [0u8; KEY_ID_LEN];
82        self.expand(INFO_KEY_ID, &mut key_id);
83        key_id
84    }
85
86    /// The working key for `suite`.
87    ///
88    /// # Errors
89    ///
90    /// [`Error::Format`] for a suite this build cannot key.
91    pub fn suite_key(&self, suite: u8) -> Result<SuiteKey> {
92        if suite != SUITE_AES_256_SIV {
93            return Err(Error::Format(format!(
94                "cipher suite {suite:#04x} needs a newer git-xcrypt"
95            )));
96        }
97        let mut key = SuiteKey([0u8; SIV_KEY_LEN]);
98        self.expand(INFO_SUITE_AES_256_SIV, &mut key.0);
99        Ok(key)
100    }
101
102    /// HKDF-SHA-256 expansion with no salt and a domain-separating `info`.
103    ///
104    /// # Panics
105    ///
106    /// Never for the call sites in this module: both output lengths are
107    /// compile-time constants far below HKDF's ceiling of 8160 bytes, and the
108    /// only documented failure is an out-of-range length. Aborting is
109    /// deliberate — the alternative shapes (a zeroed buffer, a half-filled one)
110    /// would encrypt real content under a predictable key and say nothing,
111    /// which is the one failure mode this codebase refuses everywhere else.
112    fn expand(&self, info: &[u8], out: &mut [u8]) {
113        const HKDF_SHA256_MAX_OUTPUT: usize = 255 * 32;
114        assert!(
115            out.len() <= HKDF_SHA256_MAX_OUTPUT,
116            "HKDF output length is out of range"
117        );
118
119        let hkdf = Hkdf::<Sha256>::new(None, &self.0);
120        hkdf.expand(info, out)
121            .expect("HKDF cannot fail for a length already checked above");
122    }
123}
124
125/// A working key derived for one cipher suite.
126///
127/// Separate type so a suite key can never be mistaken for the master key.
128#[derive(Zeroize, ZeroizeOnDrop)]
129pub struct SuiteKey([u8; SIV_KEY_LEN]);
130
131impl SuiteKey {
132    /// The raw key material, for handing to the cipher.
133    #[must_use]
134    pub fn expose_bytes(&self) -> &[u8; SIV_KEY_LEN] {
135        &self.0
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn fixed_key() -> MasterKey {
144        MasterKey::from_bytes([7u8; MASTER_KEY_LEN])
145    }
146
147    #[test]
148    fn key_id_is_stable_for_the_same_key() {
149        assert_eq!(fixed_key().key_id(), fixed_key().key_id());
150    }
151
152    #[test]
153    fn the_suite_key_is_not_the_master_key() {
154        let key = fixed_key();
155        let suite = key.suite_key(SUITE_AES_256_SIV).expect("known suite");
156        assert_ne!(&suite.expose_bytes()[..MASTER_KEY_LEN], key.expose_bytes());
157    }
158
159    #[test]
160    fn the_suite_key_does_not_start_with_the_key_id() {
161        // Both come from the same master key; domain separation must keep them
162        // independent rather than sharing a prefix.
163        let key = fixed_key();
164        let suite = key.suite_key(SUITE_AES_256_SIV).expect("known suite");
165        assert_ne!(&suite.expose_bytes()[..KEY_ID_LEN], &key.key_id());
166    }
167
168    #[test]
169    fn an_unknown_suite_has_no_key() {
170        assert!(fixed_key().suite_key(0xff).is_err());
171    }
172}