git_xcrypt/crypto/
keyfile.rs1use std::fs;
18use std::path::Path;
19
20use base64::Engine as _;
21use base64::engine::general_purpose::STANDARD as BASE64;
22use zeroize::{Zeroize as _, Zeroizing};
23
24use crate::crypto::format::KEY_ID_LEN;
25use crate::crypto::key::{MASTER_KEY_LEN, MasterKey};
26use crate::{Error, Result};
27
28const KEY_FILE_MAGIC: &[u8] = b"\0GITXCRYPTKEY\0";
30
31const KEY_FILE_VERSION: u8 = 1;
33
34const KEY_FILE_LEN: usize = KEY_FILE_MAGIC.len() + 1 + MASTER_KEY_LEN;
36
37#[must_use]
58pub fn holds_a_key(content: &[u8]) -> bool {
59 if content.starts_with(KEY_FILE_MAGIC) {
60 return true;
61 }
62 std::str::from_utf8(content).is_ok_and(|text| {
63 significant_lines(text)
64 .next()
65 .is_some_and(|line| line.starts_with(EXPORT_PREFIX))
66 })
67}
68
69fn significant_lines(text: &str) -> impl Iterator<Item = &str> {
77 text.lines()
78 .map(str::trim)
79 .filter(|line| !line.is_empty() && !line.starts_with('#'))
80}
81
82fn encode(key: &MasterKey) -> Zeroizing<Vec<u8>> {
88 let mut bytes = Vec::with_capacity(KEY_FILE_LEN);
89 bytes.extend_from_slice(KEY_FILE_MAGIC);
90 bytes.push(KEY_FILE_VERSION);
91 bytes.extend_from_slice(key.expose_bytes());
92 Zeroizing::new(bytes)
93}
94
95fn decode(bytes: &[u8]) -> Result<MasterKey> {
97 if bytes.len() != KEY_FILE_LEN || !bytes.starts_with(KEY_FILE_MAGIC) {
98 return Err(Error::Format("this is not a git-xcrypt key file".into()));
99 }
100 let version = bytes[KEY_FILE_MAGIC.len()];
101 if version != KEY_FILE_VERSION {
102 return Err(Error::Format(format!(
103 "key file version {version} needs a newer git-xcrypt"
104 )));
105 }
106
107 let mut material = [0u8; MASTER_KEY_LEN];
108 material.copy_from_slice(&bytes[KEY_FILE_MAGIC.len() + 1..]);
109 let key = MasterKey::from_bytes(material);
110 material.zeroize();
111 Ok(key)
112}
113
114pub fn write(path: &Path, key: &MasterKey) -> Result<()> {
125 if let Some(parent) = path.parent() {
126 fs::create_dir_all(parent)?;
127 }
128 write_owner_only(path, &encode(key))
129}
130
131pub fn read(path: &Path) -> Result<MasterKey> {
138 let bytes = match fs::read(path) {
140 Ok(bytes) => Zeroizing::new(bytes),
141 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Err(Error::NoKey),
142 Err(err) => return Err(Error::Io(err)),
143 };
144 decode(&bytes)
145}
146
147pub fn write_owner_only(path: &Path, contents: &[u8]) -> Result<()> {
157 crate::util::atomic::write_owner_only(path, contents)
158}
159
160const EXPORT_PREFIX: &str = "git-xcrypt-key-v";
166
167const EXPORT_VERSION: u32 = 1;
169
170#[must_use]
176pub fn encode_portable(key: &MasterKey) -> Zeroizing<String> {
177 let capacity = EXPORT_PREFIX.len() + 4 + KEY_ID_LEN * 2 + MASTER_KEY_LEN.div_ceil(3) * 4 + 3;
181 let mut text = String::with_capacity(capacity);
182 text.push_str(EXPORT_PREFIX);
183 text.push_str(&EXPORT_VERSION.to_string());
184 text.push(' ');
185 text.push_str(&crate::format_key_id(&key.key_id()));
186 text.push('\n');
187 let encoded = Zeroizing::new(BASE64.encode(key.expose_bytes()));
191 text.push_str(&encoded);
192 text.push('\n');
193 debug_assert!(
194 text.len() <= capacity,
195 "the export buffer grew, so a copy of the key was left on the heap"
196 );
197 Zeroizing::new(text)
198}
199
200pub fn decode_portable(text: &str) -> Result<MasterKey> {
212 let mut lines = significant_lines(text);
213
214 let header = lines
215 .next()
216 .ok_or_else(|| Error::Format("this is not a git-xcrypt key file".into()))?;
217 let declared = parse_export_header(header)?;
218
219 let encoded = lines
220 .next()
221 .ok_or_else(|| Error::Format("the key file has a header but no key".into()))?;
222 if lines.next().is_some() {
223 return Err(Error::Format(
224 "the key file carries more than one key; refusing to guess which one is meant".into(),
225 ));
226 }
227
228 let material = Zeroizing::new(BASE64.decode(encoded).map_err(|err| {
230 Error::Format(format!(
231 "the key in this file is not readable base64: {err}"
232 ))
233 })?);
234 if material.len() != MASTER_KEY_LEN {
235 return Err(Error::Format(format!(
236 "a repository key is {MASTER_KEY_LEN} bytes; this file holds {}",
237 material.len()
238 )));
239 }
240
241 let mut bytes = [0u8; MASTER_KEY_LEN];
242 bytes.copy_from_slice(&material);
243 let key = MasterKey::from_bytes(bytes);
244 bytes.zeroize();
245
246 if key.key_id() != declared {
247 return Err(Error::Format(format!(
248 "this key file says it holds key {}, but its key material is {} — \
249 it was truncated or edited in transit",
250 crate::format_key_id(&declared),
251 crate::format_key_id(&key.key_id())
252 )));
253 }
254
255 Ok(key)
256}
257
258fn parse_export_header(header: &str) -> Result<[u8; KEY_ID_LEN]> {
260 let rest = header
261 .strip_prefix(EXPORT_PREFIX)
262 .ok_or_else(|| Error::Format("this is not a git-xcrypt key file".into()))?;
263 let (version, key_id) = rest
264 .split_once(' ')
265 .ok_or_else(|| Error::Format("the key file header names no key".into()))?;
266
267 if version.parse::<u32>().ok() != Some(EXPORT_VERSION) {
268 return Err(Error::Format(format!(
269 "key file version {version} needs a newer git-xcrypt"
270 )));
271 }
272
273 parse_key_id(key_id.trim())
274}
275
276fn parse_key_id(text: &str) -> Result<[u8; KEY_ID_LEN]> {
278 if !text.is_ascii() {
286 return Err(Error::Format(format!(
287 "`{text}` is not a key fingerprint; expected {} hex digits",
288 KEY_ID_LEN * 2
289 )));
290 }
291 if text.len() != KEY_ID_LEN * 2 {
292 return Err(Error::Format(format!(
293 "`{text}` is not a key fingerprint; expected {} hex digits",
294 KEY_ID_LEN * 2
295 )));
296 }
297
298 let mut key_id = [0u8; KEY_ID_LEN];
299 for (index, byte) in key_id.iter_mut().enumerate() {
300 *byte = u8::from_str_radix(&text[index * 2..index * 2 + 2], 16)
301 .map_err(|_| Error::Format(format!("`{text}` is not a key fingerprint")))?;
302 }
303 Ok(key_id)
304}
305
306pub fn write_portable(path: &Path, key: &MasterKey) -> Result<()> {
312 write_owner_only(path, encode_portable(key).as_bytes())
313}
314
315pub fn read_portable(path: &Path) -> Result<MasterKey> {
323 let text = match fs::read_to_string(path) {
325 Ok(text) => Zeroizing::new(text),
326 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
327 return Err(Error::Usage(format!(
328 "{}: no such key file",
329 path.display()
330 )));
331 }
332 Err(err) if err.kind() == std::io::ErrorKind::InvalidData => {
333 return Err(Error::Format(format!(
334 "{}: not a git-xcrypt key file — it is not even text",
335 path.display()
336 )));
337 }
338 Err(err) => return Err(Error::Io(err)),
339 };
340
341 decode_portable(&text).map_err(|err| match err {
342 Error::Format(message) => Error::Format(format!("{}: {message}", path.display())),
343 other => other,
344 })
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 #[cfg(unix)]
351 use tempfile::TempDir;
352
353 #[cfg(unix)]
354 #[test]
355 fn a_pre_existing_loose_file_is_narrowed_before_the_key_lands_in_it() {
356 use std::os::unix::fs::PermissionsExt as _;
357
358 let dir = TempDir::new().expect("temporary directory");
359 let path = dir.path().join("default");
360 fs::write(&path, b"world readable placeholder").expect("writing must succeed");
361 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod must succeed");
362
363 write(&path, &MasterKey::from_bytes([4u8; MASTER_KEY_LEN])).expect("writing must succeed");
364
365 let mode = fs::metadata(&path)
366 .expect("the key file")
367 .permissions()
368 .mode();
369 assert_eq!(
370 mode & 0o777,
371 0o600,
372 "an existing key file kept its loose permissions"
373 );
374 }
375
376 #[test]
377 fn every_shape_decode_portable_accepts_is_recognised_as_a_key() {
378 let key = MasterKey::from_bytes([41u8; MASTER_KEY_LEN]);
384 let exported = encode_portable(&key);
385 let mut lines = exported.lines();
386 let (header, material) = (lines.next().expect("header"), lines.next().expect("key"));
387
388 for (name, text) in [
389 ("as written", exported.to_string()),
390 (
391 "annotated in a password manager",
392 format!("# my laptop, 2026-08-04\n{header}\n{material}\n"),
393 ),
394 ("a leading blank line", format!("\n{header}\n{material}\n")),
395 ("indented by a paste", format!(" {header}\n {material}\n")),
396 (
397 "CRLF from an email body",
398 format!("{header}\r\n{material}\r\n"),
399 ),
400 ] {
401 assert!(
402 decode_portable(&text).is_ok(),
403 "`{name}` stopped being a key file, so this test proves nothing"
404 );
405 assert!(
406 holds_a_key(text.as_bytes()),
407 "`{name}` is a usable key file that the diff driver would have printed"
408 );
409 }
410 }
411
412 #[cfg(unix)]
413 #[test]
414 fn a_portable_key_file_is_owner_only() {
415 use std::os::unix::fs::PermissionsExt as _;
416
417 let dir = TempDir::new().expect("temporary directory");
418 let path = dir.path().join("exported.key");
419 write_portable(&path, &MasterKey::from_bytes([28u8; MASTER_KEY_LEN]))
420 .expect("writing must succeed");
421
422 let mode = fs::metadata(&path).expect("metadata").permissions().mode();
423 assert_eq!(
424 mode & 0o777,
425 0o600,
426 "an exported key must not be readable by others"
427 );
428 }
429
430 #[cfg(unix)]
431 #[test]
432 fn the_key_file_is_owner_only() {
433 use std::os::unix::fs::PermissionsExt as _;
434
435 let dir = TempDir::new().expect("temporary directory");
436 let path = dir.path().join("default");
437 write(&path, &MasterKey::from_bytes([3u8; MASTER_KEY_LEN])).expect("writing must succeed");
438
439 let mode = fs::metadata(&path)
440 .expect("the key file must exist")
441 .permissions()
442 .mode();
443 assert_eq!(
444 mode & 0o777,
445 0o600,
446 "the key file must not be readable by others"
447 );
448 }
449}