git_xcrypt/crypto/
format.rs1use crate::{Error, Result};
21
22pub const MAGIC: [u8; 11] = *b"\0GITXCRYPT\0";
27
28pub const FORMAT_VERSION: u8 = 1;
30
31pub const SUITE_AES_256_SIV: u8 = 1;
33
34pub const FLAG_LF_NORMALIZED: u8 = 0b0000_0001;
36
37const KNOWN_FLAGS: u8 = FLAG_LF_NORMALIZED;
39
40pub const KEY_ID_LEN: usize = 8;
42
43pub const HEADER_LEN: usize = 22;
45
46pub const SIV_LEN: usize = 16;
48
49pub const OVERHEAD: usize = HEADER_LEN + SIV_LEN;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Header {
55 pub version: u8,
57 pub suite: u8,
59 pub flags: u8,
61 pub key_id: [u8; KEY_ID_LEN],
63}
64
65impl Header {
66 #[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 #[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 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#[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 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}