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}