Skip to main content

git_xcrypt/crypto/
keyfile.rs

1//! Reading and writing the repository key on disk.
2//!
3//! Two shapes, both holding the same 32-byte **master key** — never a cipher
4//! key, so a future suite cannot strand either of them:
5//!
6//! * the binary file in `.git/git-xcrypt/keys/`, which the tool reads on every
7//!   filter run and no human ever looks at;
8//! * the portable text file [`encode_portable`] produces, which is what
9//!   `export-key` writes and `unlock` reads. It is text so it
10//!   survives a password manager, an email body and a copy-paste, and it names
11//!   its `key_id` in the clear so a user can tell two exports apart without
12//!   decrypting anything.
13//!
14//! Both carry their own magic and version, independent of the data format's,
15//! because the three evolve for different reasons.
16
17use 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
28/// Identifies a key file and keeps it from being mistaken for anything else.
29const KEY_FILE_MAGIC: &[u8] = b"\0GITXCRYPTKEY\0";
30
31/// The only key file version written today.
32const KEY_FILE_VERSION: u8 = 1;
33
34/// Total length of a key file: magic, version byte, master key.
35const KEY_FILE_LEN: usize = KEY_FILE_MAGIC.len() + 1 + MASTER_KEY_LEN;
36
37/// Whether `content` is one of this tool's key files, in either shape.
38///
39/// For the diff driver, which git hands arbitrary paths and which prints what it
40/// reads. A key file carries neither the data magic nor anything else that would
41/// stop it going straight to `stdout`, and `git-xcrypt diff <key> > k` would put
42/// it in the working tree, one `git add -A` from a commit. Deciding on the
43/// content rather than on the location is what makes the refusal hold for an
44/// exported copy, for a hard link and whatever the current directory is.
45///
46/// **It has to recognise exactly what [`decode_portable`] accepts**, which is
47/// why both go through [`significant_lines`] rather than each having their own
48/// idea of where the file starts. Measured before that: one `# my laptop` line
49/// above the header — the annotation a key picks up in a password manager, and
50/// a shape this module has a test for — made the header stop being the first
51/// byte, so the check missed it and `git-xcrypt diff` printed the repository's
52/// master key in base64 with exit code 0. A leading blank line and leading
53/// spaces did the same. All three still imported as a working key.
54///
55/// Content that is not UTF-8 cannot be a portable key at all: [`read_portable`]
56/// reads the file as text, so such a file would never be accepted as one.
57#[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
69/// The lines of a portable key file that carry anything.
70///
71/// Blank lines and `#` comments are skipped and surrounding whitespace comes
72/// off, because a key travelling through a password manager or an email body
73/// picks all three up. Shared with [`holds_a_key`] deliberately: the parser and
74/// the refusal disagreeing about where the file begins is a hole through which
75/// the key reaches `stdout`.
76fn 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
82/// Serialises a key into the bytes stored on disk.
83///
84/// The buffer holds the master key, so it is wrapped in [`Zeroizing`]: without
85/// that, `MasterKey`'s own `ZeroizeOnDrop` would protect one copy of the key and
86/// leave this one behind on the heap.
87fn 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
95/// Parses the bytes of a key file.
96fn 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
114/// Writes `key` to `path`, creating parent directories.
115///
116/// The file is owner-only before any key material reaches it: on Unix the
117/// replacement is created with mode `0600` and renamed into place, so neither a
118/// fresh file nor one that already existed with looser permissions has a moment
119/// in which the key is world readable.
120///
121/// # Errors
122///
123/// [`Error::Io`] when the directory or the file cannot be created.
124pub 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
131/// Reads the key stored at `path`.
132///
133/// # Errors
134///
135/// [`Error::NoKey`] when the file is absent, [`Error::Format`] when it is not a
136/// key file this build understands.
137pub fn read(path: &Path) -> Result<MasterKey> {
138    // Zeroizing, because this buffer is a full copy of the master key.
139    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
147/// Creates `path` with owner-only permissions and writes `contents`.
148///
149/// Shared with `export-key`, which has the same requirement. The replacement is
150/// atomic: a key file caught half-written is a repository nobody can ever
151/// decrypt again, so there must be no moment in which one exists.
152///
153/// # Errors
154///
155/// [`Error::Io`] when the file cannot be created or written.
156pub fn write_owner_only(path: &Path, contents: &[u8]) -> Result<()> {
157    crate::util::atomic::write_owner_only(path, contents)
158}
159
160/// Name and version of the portable export format, as its first line begins.
161///
162/// Its own version, deliberately: this file lives in users' password managers
163/// and backups, so it is frozen for reasons that have nothing to do with the
164/// data format or with which cipher suite is current.
165const EXPORT_PREFIX: &str = "git-xcrypt-key-v";
166
167/// The only portable version written today.
168const EXPORT_VERSION: u32 = 1;
169
170/// Renders `key` in the portable text form `export-key` writes.
171///
172/// Two significant lines: a header naming the format, its version and the
173/// `key_id` in hex, then the master key in base64. The buffer is a full copy of
174/// the key, hence [`Zeroizing`].
175#[must_use]
176pub fn encode_portable(key: &MasterKey) -> Zeroizing<String> {
177    // Sized from the constants rather than guessed: the `Zeroizing` below only
178    // protects this buffer if it is never reallocated, and a reallocation would
179    // leave the half-built text — key included — behind on the heap.
180    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    // `encode` allocates a buffer of its own holding the whole key; wrapping the
188    // outer string and leaving that one on the heap would protect one copy of
189    // two.
190    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
200/// Parses the portable text form.
201///
202/// Blank lines and `#` comments are skipped, because a key travelling through a
203/// password manager or an email body picks them up. Everything else fails
204/// closed: an unknown version, a key of the wrong length, trailing content, or a
205/// `key_id` that does not match the material below it — the last of which is how
206/// a truncated or hand-edited copy announces itself before it is imported.
207///
208/// # Errors
209///
210/// [`Error::Format`] for anything this build cannot read as a key.
211pub 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    // Zeroizing: this is the master key in the clear, one decode away.
229    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
258/// Reads the `git-xcrypt-key-v<n> <key_id>` line.
259fn 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
276/// Parses a `key_id` written as sixteen hex digits.
277fn parse_key_id(text: &str) -> Result<[u8; KEY_ID_LEN]> {
278    // Refused before the slicing below, not only for the message: the length
279    // gate counts bytes, and `&text[0..2]` on a header holding multi-byte
280    // characters lands inside one and panics — measured, reading a key file on a
281    // file whose header read `git-xcrypt-key-v1 a\u{20ac}\u{20ac}\u{20ac}\u{20ac}\u{20ac}` (sixteen bytes,
282    // six characters) aborted with `byte index 2 is not a char boundary`
283    // instead of naming the file. A key file is user input, and the convention
284    // is no panic on user input.
285    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
306/// Writes `key` to `path` in the portable form, owner-only.
307///
308/// # Errors
309///
310/// [`Error::Io`] when the file cannot be created or written.
311pub fn write_portable(path: &Path, key: &MasterKey) -> Result<()> {
312    write_owner_only(path, encode_portable(key).as_bytes())
313}
314
315/// Reads a key from a portable file.
316///
317/// # Errors
318///
319/// [`Error::Usage`] when the file the user named is not there, [`Error::Io`]
320/// when it cannot be read, [`Error::Format`] when it is not a key file this
321/// build understands.
322pub fn read_portable(path: &Path) -> Result<MasterKey> {
323    // Zeroizing: the text holds the key, base64 or not.
324    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        // The refusal in the diff driver is content-based, so it has to cover
379        // exactly what the parser accepts. It did not: one `#` line above the
380        // header moved the header off byte zero, `holds_a_key` said no and
381        // `git-xcrypt diff` printed the master key in base64 with exit code 0.
382        // Measured, on all three paddings below, each of which still imported.
383        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}