Skip to main content

git_xcrypt/crypto/
format.rs

1//! The on-disk format of an encrypted file.
2//!
3//! Bytes `0..22` are frozen forever and are fed to the cipher as associated
4//! data, so nothing in the header can be altered without invalidating the tag.
5//! Everything from offset 22 onwards is defined by `suite`, which is what lets a
6//! future cipher, a chunked mode or compression arrive without a format version
7//! bump and without breaking existing repositories.
8//!
9//! ```text
10//! offset  len  field
11//!      0   11  magic \x00GITXCRYPT\x00
12//!     11    1  format_version
13//!     12    1  suite
14//!     13    1  flags
15//!     14    8  key_id
16//!     22   16  synthetic IV      <- suite-defined
17//!     38   ..  ciphertext        <- suite-defined
18//! ```
19
20use crate::{Error, Result};
21
22/// Leading bytes identifying our format.
23///
24/// The leading NUL makes git's own heuristics treat the blob as binary, so
25/// `git diff` reports `Binary files differ` instead of dumping noise.
26pub const MAGIC: [u8; 11] = *b"\0GITXCRYPT\0";
27
28/// The only format version written today.
29pub const FORMAT_VERSION: u8 = 1;
30
31/// AES-256-SIV (RFC 5297).
32pub const SUITE_AES_256_SIV: u8 = 1;
33
34/// Bit 0 of `flags`: the plaintext was normalised to LF before encryption.
35pub const FLAG_LF_NORMALIZED: u8 = 0b0000_0001;
36
37/// Every flag bit we understand. Anything else must be refused.
38const KNOWN_FLAGS: u8 = FLAG_LF_NORMALIZED;
39
40/// Length of the key fingerprint carried by every file.
41pub const KEY_ID_LEN: usize = 8;
42
43/// Length of the frozen header, all of which is authenticated.
44pub const HEADER_LEN: usize = 22;
45
46/// Length of the synthetic IV produced by AES-SIV.
47pub const SIV_LEN: usize = 16;
48
49/// Constant number of bytes an encrypted file adds to its plaintext.
50pub const OVERHEAD: usize = HEADER_LEN + SIV_LEN;
51
52/// The parsed header of an encrypted file.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Header {
55    /// Format version; only [`FORMAT_VERSION`] is accepted today.
56    pub version: u8,
57    /// Cipher suite; only [`SUITE_AES_256_SIV`] is accepted today.
58    pub suite: u8,
59    /// Conversion flags recorded at encryption time.
60    pub flags: u8,
61    /// Fingerprint of the master key this file was encrypted with.
62    pub key_id: [u8; KEY_ID_LEN],
63}
64
65impl Header {
66    /// Builds a header for the suite and version we write today.
67    #[must_use]
68    pub fn new(flags: u8, key_id: [u8; KEY_ID_LEN]) -> Self {
69        Self {
70            version: FORMAT_VERSION,
71            suite: SUITE_AES_256_SIV,
72            flags,
73            key_id,
74        }
75    }
76
77    /// Serialises the header exactly as it appears at the start of a file.
78    #[must_use]
79    pub fn to_bytes(self) -> [u8; HEADER_LEN] {
80        let mut bytes = [0u8; HEADER_LEN];
81        bytes[..MAGIC.len()].copy_from_slice(&MAGIC);
82        bytes[11] = self.version;
83        bytes[12] = self.suite;
84        bytes[13] = self.flags;
85        bytes[14..HEADER_LEN].copy_from_slice(&self.key_id);
86        bytes
87    }
88
89    /// Parses the header at the start of `blob`, refusing anything unknown.
90    ///
91    /// Fail closed is the rule here: an older binary meeting a newer file must
92    /// stop rather than guess. That covers an unknown format version, an
93    /// unknown suite and any reserved flag bit being set.
94    ///
95    /// # Errors
96    ///
97    /// [`Error::Format`] when the blob is too short, lacks the magic, or names
98    /// a version, suite or flag bit this build does not understand.
99    pub fn parse(blob: &[u8]) -> Result<Self> {
100        if blob.len() < OVERHEAD {
101            return Err(Error::Format(
102                "the file is shorter than an empty encrypted file".into(),
103            ));
104        }
105        if !blob.starts_with(&MAGIC) {
106            return Err(Error::Format("the file does not carry our magic".into()));
107        }
108
109        let version = blob[11];
110        if version != FORMAT_VERSION {
111            return Err(Error::Format(format!(
112                "format version {version} needs a newer git-xcrypt"
113            )));
114        }
115
116        let suite = blob[12];
117        if suite != SUITE_AES_256_SIV {
118            return Err(Error::Format(format!(
119                "cipher suite {suite:#04x} needs a newer git-xcrypt"
120            )));
121        }
122
123        let flags = blob[13];
124        if flags & !KNOWN_FLAGS != 0 {
125            return Err(Error::Format(format!(
126                "flags {flags:#010b} set a reserved bit; this file needs a newer git-xcrypt"
127            )));
128        }
129
130        let mut key_id = [0u8; KEY_ID_LEN];
131        key_id.copy_from_slice(&blob[14..HEADER_LEN]);
132
133        Ok(Self {
134            version,
135            suite,
136            flags,
137            key_id,
138        })
139    }
140}
141
142/// Whether `content` carries our magic.
143///
144/// This is the cheap check every path starts with — the filter, `status` and
145/// the history scan all decide on these eleven bytes alone.
146#[must_use]
147pub fn looks_encrypted(content: &[u8]) -> bool {
148    content.starts_with(&MAGIC)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    const KEY_ID: [u8; KEY_ID_LEN] = [0x3f, 0xa9, 0x12, 0x0b, 0x7e, 0xc4, 0x55, 0x8a];
156
157    /// A blob whose header is valid and whose body is long enough to parse.
158    fn blob_with(version: u8, suite: u8, flags: u8) -> Vec<u8> {
159        let mut header = Header::new(flags, KEY_ID).to_bytes();
160        header[11] = version;
161        header[12] = suite;
162        let mut blob = header.to_vec();
163        blob.extend_from_slice(&[0u8; SIV_LEN]);
164        blob
165    }
166
167    #[test]
168    fn magic_starts_with_nul_so_git_sees_binary() {
169        assert_eq!(MAGIC[0], 0);
170        assert_eq!(MAGIC[MAGIC.len() - 1], 0);
171    }
172
173    #[test]
174    fn overhead_is_thirty_eight_bytes() {
175        assert_eq!(OVERHEAD, 38);
176        assert_eq!(Header::new(0, KEY_ID).to_bytes().len(), HEADER_LEN);
177    }
178
179    #[test]
180    fn an_unknown_version_is_refused() {
181        let blob = blob_with(FORMAT_VERSION + 1, SUITE_AES_256_SIV, 0);
182        assert!(Header::parse(&blob).is_err());
183    }
184
185    #[test]
186    fn an_unknown_suite_is_refused() {
187        let blob = blob_with(FORMAT_VERSION, SUITE_AES_256_SIV + 1, 0);
188        assert!(Header::parse(&blob).is_err());
189    }
190
191    #[test]
192    fn a_reserved_flag_bit_is_refused() {
193        for bit in 1..8 {
194            let blob = blob_with(FORMAT_VERSION, SUITE_AES_256_SIV, 1 << bit);
195            assert!(
196                Header::parse(&blob).is_err(),
197                "flag bit {bit} must be refused until it means something"
198            );
199        }
200    }
201}