use std::fs;
use std::path::Path;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use zeroize::{Zeroize as _, Zeroizing};
use crate::crypto::format::KEY_ID_LEN;
use crate::crypto::key::{MASTER_KEY_LEN, MasterKey};
use crate::{Error, Result};
const KEY_FILE_MAGIC: &[u8] = b"\0GITXCRYPTKEY\0";
const KEY_FILE_VERSION: u8 = 1;
const KEY_FILE_LEN: usize = KEY_FILE_MAGIC.len() + 1 + MASTER_KEY_LEN;
#[must_use]
pub fn holds_a_key(content: &[u8]) -> bool {
if content.starts_with(KEY_FILE_MAGIC) {
return true;
}
std::str::from_utf8(content).is_ok_and(|text| {
significant_lines(text)
.next()
.is_some_and(|line| line.starts_with(EXPORT_PREFIX))
})
}
fn significant_lines(text: &str) -> impl Iterator<Item = &str> {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
}
fn encode(key: &MasterKey) -> Zeroizing<Vec<u8>> {
let mut bytes = Vec::with_capacity(KEY_FILE_LEN);
bytes.extend_from_slice(KEY_FILE_MAGIC);
bytes.push(KEY_FILE_VERSION);
bytes.extend_from_slice(key.expose_bytes());
Zeroizing::new(bytes)
}
fn decode(bytes: &[u8]) -> Result<MasterKey> {
if bytes.len() != KEY_FILE_LEN || !bytes.starts_with(KEY_FILE_MAGIC) {
return Err(Error::Format("this is not a git-xcrypt key file".into()));
}
let version = bytes[KEY_FILE_MAGIC.len()];
if version != KEY_FILE_VERSION {
return Err(Error::Format(format!(
"key file version {version} needs a newer git-xcrypt"
)));
}
let mut material = [0u8; MASTER_KEY_LEN];
material.copy_from_slice(&bytes[KEY_FILE_MAGIC.len() + 1..]);
let key = MasterKey::from_bytes(material);
material.zeroize();
Ok(key)
}
pub fn write(path: &Path, key: &MasterKey) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
write_owner_only(path, &encode(key))
}
pub fn read(path: &Path) -> Result<MasterKey> {
let bytes = match fs::read(path) {
Ok(bytes) => Zeroizing::new(bytes),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Err(Error::NoKey),
Err(err) => return Err(Error::Io(err)),
};
decode(&bytes)
}
pub fn write_owner_only(path: &Path, contents: &[u8]) -> Result<()> {
crate::util::atomic::write_owner_only(path, contents)
}
const EXPORT_PREFIX: &str = "git-xcrypt-key-v";
const EXPORT_VERSION: u32 = 1;
#[must_use]
pub fn encode_portable(key: &MasterKey) -> Zeroizing<String> {
let capacity = EXPORT_PREFIX.len() + 4 + KEY_ID_LEN * 2 + MASTER_KEY_LEN.div_ceil(3) * 4 + 3;
let mut text = String::with_capacity(capacity);
text.push_str(EXPORT_PREFIX);
text.push_str(&EXPORT_VERSION.to_string());
text.push(' ');
text.push_str(&crate::format_key_id(&key.key_id()));
text.push('\n');
let encoded = Zeroizing::new(BASE64.encode(key.expose_bytes()));
text.push_str(&encoded);
text.push('\n');
debug_assert!(
text.len() <= capacity,
"the export buffer grew, so a copy of the key was left on the heap"
);
Zeroizing::new(text)
}
pub fn decode_portable(text: &str) -> Result<MasterKey> {
let mut lines = significant_lines(text);
let header = lines
.next()
.ok_or_else(|| Error::Format("this is not a git-xcrypt key file".into()))?;
let declared = parse_export_header(header)?;
let encoded = lines
.next()
.ok_or_else(|| Error::Format("the key file has a header but no key".into()))?;
if lines.next().is_some() {
return Err(Error::Format(
"the key file carries more than one key; refusing to guess which one is meant".into(),
));
}
let material = Zeroizing::new(BASE64.decode(encoded).map_err(|err| {
Error::Format(format!(
"the key in this file is not readable base64: {err}"
))
})?);
if material.len() != MASTER_KEY_LEN {
return Err(Error::Format(format!(
"a repository key is {MASTER_KEY_LEN} bytes; this file holds {}",
material.len()
)));
}
let mut bytes = [0u8; MASTER_KEY_LEN];
bytes.copy_from_slice(&material);
let key = MasterKey::from_bytes(bytes);
bytes.zeroize();
if key.key_id() != declared {
return Err(Error::Format(format!(
"this key file says it holds key {}, but its key material is {} — \
it was truncated or edited in transit",
crate::format_key_id(&declared),
crate::format_key_id(&key.key_id())
)));
}
Ok(key)
}
fn parse_export_header(header: &str) -> Result<[u8; KEY_ID_LEN]> {
let rest = header
.strip_prefix(EXPORT_PREFIX)
.ok_or_else(|| Error::Format("this is not a git-xcrypt key file".into()))?;
let (version, key_id) = rest
.split_once(' ')
.ok_or_else(|| Error::Format("the key file header names no key".into()))?;
if version.parse::<u32>().ok() != Some(EXPORT_VERSION) {
return Err(Error::Format(format!(
"key file version {version} needs a newer git-xcrypt"
)));
}
parse_key_id(key_id.trim())
}
fn parse_key_id(text: &str) -> Result<[u8; KEY_ID_LEN]> {
if !text.is_ascii() {
return Err(Error::Format(format!(
"`{text}` is not a key fingerprint; expected {} hex digits",
KEY_ID_LEN * 2
)));
}
if text.len() != KEY_ID_LEN * 2 {
return Err(Error::Format(format!(
"`{text}` is not a key fingerprint; expected {} hex digits",
KEY_ID_LEN * 2
)));
}
let mut key_id = [0u8; KEY_ID_LEN];
for (index, byte) in key_id.iter_mut().enumerate() {
*byte = u8::from_str_radix(&text[index * 2..index * 2 + 2], 16)
.map_err(|_| Error::Format(format!("`{text}` is not a key fingerprint")))?;
}
Ok(key_id)
}
pub fn write_portable(path: &Path, key: &MasterKey) -> Result<()> {
write_owner_only(path, encode_portable(key).as_bytes())
}
pub fn read_portable(path: &Path) -> Result<MasterKey> {
let text = match fs::read_to_string(path) {
Ok(text) => Zeroizing::new(text),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::Usage(format!(
"{}: no such key file",
path.display()
)));
}
Err(err) if err.kind() == std::io::ErrorKind::InvalidData => {
return Err(Error::Format(format!(
"{}: not a git-xcrypt key file — it is not even text",
path.display()
)));
}
Err(err) => return Err(Error::Io(err)),
};
decode_portable(&text).map_err(|err| match err {
Error::Format(message) => Error::Format(format!("{}: {message}", path.display())),
other => other,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use tempfile::TempDir;
#[cfg(unix)]
#[test]
fn a_pre_existing_loose_file_is_narrowed_before_the_key_lands_in_it() {
use std::os::unix::fs::PermissionsExt as _;
let dir = TempDir::new().expect("temporary directory");
let path = dir.path().join("default");
fs::write(&path, b"world readable placeholder").expect("writing must succeed");
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod must succeed");
write(&path, &MasterKey::from_bytes([4u8; MASTER_KEY_LEN])).expect("writing must succeed");
let mode = fs::metadata(&path)
.expect("the key file")
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"an existing key file kept its loose permissions"
);
}
#[test]
fn every_shape_decode_portable_accepts_is_recognised_as_a_key() {
let key = MasterKey::from_bytes([41u8; MASTER_KEY_LEN]);
let exported = encode_portable(&key);
let mut lines = exported.lines();
let (header, material) = (lines.next().expect("header"), lines.next().expect("key"));
for (name, text) in [
("as written", exported.to_string()),
(
"annotated in a password manager",
format!("# my laptop, 2026-08-04\n{header}\n{material}\n"),
),
("a leading blank line", format!("\n{header}\n{material}\n")),
("indented by a paste", format!(" {header}\n {material}\n")),
(
"CRLF from an email body",
format!("{header}\r\n{material}\r\n"),
),
] {
assert!(
decode_portable(&text).is_ok(),
"`{name}` stopped being a key file, so this test proves nothing"
);
assert!(
holds_a_key(text.as_bytes()),
"`{name}` is a usable key file that the diff driver would have printed"
);
}
}
#[cfg(unix)]
#[test]
fn a_portable_key_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let dir = TempDir::new().expect("temporary directory");
let path = dir.path().join("exported.key");
write_portable(&path, &MasterKey::from_bytes([28u8; MASTER_KEY_LEN]))
.expect("writing must succeed");
let mode = fs::metadata(&path).expect("metadata").permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"an exported key must not be readable by others"
);
}
#[cfg(unix)]
#[test]
fn the_key_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let dir = TempDir::new().expect("temporary directory");
let path = dir.path().join("default");
write(&path, &MasterKey::from_bytes([3u8; MASTER_KEY_LEN])).expect("writing must succeed");
let mode = fs::metadata(&path)
.expect("the key file must exist")
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"the key file must not be readable by others"
);
}
}