Skip to main content

git_xcrypt/crypto/
cipher.rs

1//! Encryption and decryption — the one pair of functions every path shares.
2//!
3//! AES-256-SIV (RFC 5297) is a deterministic AEAD: the synthetic IV is computed
4//! from the content, so identical plaintext under identical key yields byte
5//! identical ciphertext. That is not a workaround, it is the construction, and
6//! it is what keeps `git status` quiet on an unchanged file.
7
8use aes_siv::KeyInit;
9use aes_siv::siv::Aes256Siv;
10
11use crate::crypto::format::{Header, KEY_ID_LEN, SUITE_AES_256_SIV};
12use crate::crypto::key::{MasterKey, SuiteKey};
13use crate::{Error, Result};
14
15/// Borrows a suite key as the cipher's own key type.
16///
17/// Both lengths are compile-time constants and both are 64 — `SIV_KEY_LEN` here
18/// and `Aes256Siv`'s key size in `aes-siv` — so this cannot fail today. It is
19/// written as an error rather than an `expect` because the two constants live
20/// in different crates: if a future suite ever moves one without the other, the
21/// right answer is a refused file, not a panic in the middle of a git
22/// operation, where `required = true` turns an abort into a broken repository.
23fn cipher_key(key: &SuiteKey) -> Result<&aes_siv::Key<Aes256Siv>> {
24    key.expose_bytes()
25        .as_slice()
26        .try_into()
27        .map_err(|_| Error::Crypto("the suite key does not fit the cipher".into()))
28}
29
30/// Encrypts `plaintext`, recording `flags` in the authenticated header.
31///
32/// The returned blob is `header || synthetic IV || ciphertext`. The header goes
33/// in as associated data, so flipping the suite or flag byte invalidates the
34/// tag instead of quietly changing how the file is read.
35///
36/// # Errors
37///
38/// [`Error::Crypto`] if the cipher refuses the input, which for a single
39/// associated-data item cannot happen in practice.
40pub fn encrypt(key: &MasterKey, flags: u8, plaintext: &[u8]) -> Result<Vec<u8>> {
41    let header = Header::new(flags, key.key_id()).to_bytes();
42    let suite_key = key.suite_key(SUITE_AES_256_SIV)?;
43
44    let mut cipher = Aes256Siv::new(cipher_key(&suite_key)?);
45    let sealed = cipher
46        .encrypt([header.as_slice()], plaintext)
47        .map_err(|_| Error::Crypto("encryption failed".into()))?;
48
49    let mut blob = Vec::with_capacity(header.len() + sealed.len());
50    blob.extend_from_slice(&header);
51    blob.extend_from_slice(&sealed);
52    Ok(blob)
53}
54
55/// Decrypts a blob produced by [`encrypt`], returning its flags and plaintext.
56///
57/// # Errors
58///
59/// [`Error::Format`] for anything the header rejects, [`Error::KeyMismatch`]
60/// when the file belongs to a different key, and [`Error::Crypto`] when the
61/// authentication tag does not verify. A failed tag is an error, never a
62/// warning: passing the bytes through would hand the caller content nobody
63/// vouched for.
64pub fn decrypt(key: &MasterKey, blob: &[u8]) -> Result<(u8, Vec<u8>)> {
65    let header = Header::parse(blob)?;
66    let our_key_id = key.key_id();
67    if header.key_id != our_key_id {
68        return Err(Error::KeyMismatch {
69            wanted: header.key_id,
70            have: our_key_id,
71        });
72    }
73
74    let suite_key = key.suite_key(header.suite)?;
75    // The associated data must be the bytes actually on disk, not a header we
76    // rebuild from expected values — rebuilding would hide a tampered byte.
77    let (header_bytes, body) = blob.split_at(crate::crypto::format::HEADER_LEN);
78
79    let mut cipher = Aes256Siv::new(cipher_key(&suite_key)?);
80    let plaintext = cipher
81        .decrypt([header_bytes], body)
82        .map_err(|_| Error::Crypto("authentication failed; the file has been altered".into()))?;
83
84    Ok((header.flags, plaintext))
85}
86
87/// The key fingerprint a blob claims, without needing the key itself.
88///
89/// Used by `status` and by `unlock` to check the key *before* touching a single
90/// file, so a wrong key fails loudly instead of half-way through.
91///
92/// # Errors
93///
94/// [`Error::Format`] when the blob is not one of ours.
95pub fn blob_key_id(blob: &[u8]) -> Result<[u8; KEY_ID_LEN]> {
96    Ok(Header::parse(blob)?.key_id)
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::crypto::format::{FLAG_LF_NORMALIZED, OVERHEAD};
103    use crate::crypto::key::MASTER_KEY_LEN;
104
105    fn key() -> MasterKey {
106        MasterKey::from_bytes([42u8; MASTER_KEY_LEN])
107    }
108
109    fn samples() -> Vec<Vec<u8>> {
110        vec![
111            Vec::new(),
112            b"x".to_vec(),
113            b"api_key = do-not-commit-me\n".to_vec(),
114            (0u8..=255).cycle().take(4096).collect(),
115        ]
116    }
117
118    #[test]
119    fn round_trips_every_shape_of_input() {
120        for plaintext in samples() {
121            let blob = encrypt(&key(), 0, &plaintext).expect("encryption must succeed");
122            let (flags, recovered) = decrypt(&key(), &blob).expect("decryption must succeed");
123            assert_eq!(flags, 0);
124            assert_eq!(recovered, plaintext);
125        }
126    }
127
128    #[test]
129    fn encryption_is_deterministic() {
130        for plaintext in samples() {
131            let first = encrypt(&key(), 0, &plaintext).expect("encryption must succeed");
132            let second = encrypt(&key(), 0, &plaintext).expect("encryption must succeed");
133            assert_eq!(first, second, "the same plaintext must give the same bytes");
134        }
135    }
136
137    proptest::proptest! {
138        // `zalozenia.md` §Jakość i testy asks for these two as *properties*, not
139        // as a list: `decrypt(encrypt(x)) == x` and `encrypt(x) == encrypt(x)`.
140        // The hand-written samples above stay because they name the shapes that
141        // once broke — empty, one byte, every byte value — and a generator that
142        // happens not to draw them would quietly stop covering them.
143        #![proptest_config(proptest::prelude::ProptestConfig::with_cases(256))]
144
145        #[test]
146        fn decrypting_what_we_encrypted_gives_the_plaintext_back(
147            plaintext in proptest::collection::vec(proptest::num::u8::ANY, 0..8192),
148            flags in proptest::prelude::prop_oneof![
149                proptest::prelude::Just(0u8),
150                proptest::prelude::Just(FLAG_LF_NORMALIZED),
151            ],
152        ) {
153            let blob = encrypt(&key(), flags, &plaintext).expect("encryption must succeed");
154            let (recovered_flags, recovered) =
155                decrypt(&key(), &blob).expect("decryption must succeed");
156            proptest::prop_assert_eq!(recovered_flags, flags);
157            proptest::prop_assert_eq!(&recovered, &plaintext);
158            // The frozen overhead, on arbitrary input rather than on four shapes.
159            proptest::prop_assert_eq!(blob.len(), plaintext.len() + OVERHEAD);
160        }
161
162        #[test]
163        fn encrypting_the_same_bytes_twice_gives_the_same_blob(
164            plaintext in proptest::collection::vec(proptest::num::u8::ANY, 0..8192),
165        ) {
166            let first = encrypt(&key(), 0, &plaintext).expect("encryption must succeed");
167            let second = encrypt(&key(), 0, &plaintext).expect("encryption must succeed");
168            proptest::prop_assert_eq!(first, second);
169        }
170    }
171
172    /// The module doc's claim, exercised on the one byte that can test it.
173    ///
174    /// "Flipping the suite or flag byte invalidates the tag" — but a flipped
175    /// suite or version is refused by `Header::parse` before any cipher runs,
176    /// so the only header byte that reaches the tag with a *valid* parse is
177    /// `flags`, flipped between its two legal values. That flip is exactly the
178    /// one that decides whether a checked-out file gets a CRLF conversion, so
179    /// it must fail authentication rather than quietly change the answer.
180    #[test]
181    fn a_flipped_flags_byte_fails_the_tag_instead_of_changing_the_conversion() {
182        let blob = encrypt(&key(), 0, b"one\ntwo\n").expect("encryption must succeed");
183        let mut flipped = blob.clone();
184        flipped[13] ^= FLAG_LF_NORMALIZED;
185        assert!(
186            crate::crypto::format::Header::parse(&flipped).is_ok(),
187            "the flipped byte must still parse, or this test asks nothing of \
188             the tag"
189        );
190        assert!(
191            matches!(decrypt(&key(), &flipped), Err(crate::Error::Crypto(_))),
192            "a header byte was altered and the tag did not notice"
193        );
194    }
195
196    /// RFC 5297 Appendix A.1 — the specification's own vector.
197    ///
198    /// It pins the crate, not our wrapper: `aes-siv` has never been audited, so
199    /// the cheapest available substitute is proving it computes what the RFC
200    /// says. The vector uses AES-128-SIV (a 256-bit key in two halves) while we
201    /// ship AES-256-SIV, but the S2V and CTR construction under test is the same.
202    #[test]
203    fn the_crate_matches_rfc_5297_appendix_a1() {
204        use aes_siv::siv::Aes128Siv;
205
206        let key = hex_bytes("fffefdfcfbfaf9f8f7f6f5f4f3f2f1f0f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff");
207        let associated_data = hex_bytes("101112131415161718191a1b1c1d1e1f2021222324252627");
208        let plaintext = hex_bytes("112233445566778899aabbccddee");
209        let expected = hex_bytes("85632d07c6e8f37f950acd320a2ecc9340c02b9690c4dc04daef7f6afe5c");
210
211        let mut cipher = Aes128Siv::new(key.as_slice().try_into().expect("a 32-byte RFC key"));
212        let sealed = cipher
213            .encrypt([associated_data.as_slice()], plaintext.as_slice())
214            .expect("the RFC vector must encrypt");
215        assert_eq!(sealed, expected, "aes-siv diverged from RFC 5297");
216
217        let mut cipher = Aes128Siv::new(key.as_slice().try_into().expect("a 32-byte RFC key"));
218        let recovered = cipher
219            .decrypt([associated_data.as_slice()], sealed.as_slice())
220            .expect("the RFC vector must decrypt");
221        assert_eq!(recovered, plaintext);
222    }
223
224    /// Parses a hex string in a test. Panics on malformed input by design.
225    fn hex_bytes(text: &str) -> Vec<u8> {
226        assert!(text.len().is_multiple_of(2), "hex needs an even length");
227        (0..text.len())
228            .step_by(2)
229            .map(|index| {
230                u8::from_str_radix(&text[index..index + 2], 16).expect("test vectors must be hex")
231            })
232            .collect()
233    }
234}