Skip to main content

rainier_crypt/
php.rs

1//! Reading what a PHP application encrypted — [`PhpEncrypter`].
2//!
3//! ```ignore
4//! // In a provider, while the ported rows are still in the PHP envelope.
5//! app.instance(Encryption::new(
6//!     Arc::new(PhpEncrypter::new(keys)),
7//!     Arc::new(HmacSigner::new(keys)),
8//! ));
9//! ```
10//!
11//! Not a preference. A ported application's database already holds columns
12//! that PHP wrote, and they have to stay readable — so this reads and writes
13//! the `{iv, value, mac}` envelope PHP MVC frameworks produce, byte for byte,
14//! against the same `APP_KEY`.
15//!
16//! # The format
17//!
18//! ```text
19//! base64( json( { "iv": base64(iv), "value": base64(ciphertext), "mac": hex(mac) } ) )
20//! ```
21//!
22//! AES-256-CBC with PKCS#7 padding, and an HMAC-SHA256 over the **base64
23//! forms** of the IV and the ciphertext concatenated — not over the raw bytes,
24//! which is the detail every reimplementation gets wrong and then cannot
25//! explain.
26//!
27//! # It is encrypt-then-MAC, and the MAC is checked first
28//!
29//! The PHP implementation checks the MAC before it decrypts, and so does
30//! this. The order is not a preference either: CBC without an authenticated
31//! MAC first is a padding oracle, and a padding oracle recovers the plaintext
32//! without the key.
33//!
34//! # Why this is not a `Cipher` variant
35//!
36//! [`Cipher`](crate::Cipher) selects the AEAD *inside* Rainier's own envelope,
37//! which names its algorithm, carries its key id and is URL-safe. The PHP
38//! format is a different envelope with none of those properties. Making it a
39//! cipher variant would mean one `Encrypter` producing two incompatible
40//! payload shapes depending on a setting, which is exactly the ambiguity the
41//! self-describing envelope exists to avoid.
42//!
43//! # Use it to migrate, not to stay
44//!
45//! Everything Rainier's own [`AeadEncrypter`](crate::AeadEncrypter) has that
46//! this does not: a key id in the payload, so rotation is possible without
47//! re-encrypting; an algorithm name, so the cipher can change; AEAD, so there
48//! is one primitive to get right instead of two composed in the correct order.
49//!
50//! The shape that works is the same one [legacy password
51//! hashes](https://docs.rs/rainier-auth) use: read with this, write with the
52//! native encrypter, and let the rows convert as they are touched.
53
54use rainier_support::setting_enum;
55
56setting_enum! {
57    /// Which payload envelope this application writes.
58    ///
59    /// Selected by `APP_CIPHER`, and deliberately a closed set: writing the
60    /// wrong envelope is not a preference that degrades gracefully, it is a
61    /// column nothing can read.
62    ///
63    /// ```
64    /// use rainier_crypt::CryptScheme;
65    /// use rainier_support::Setting;
66    ///
67    /// assert_eq!(CryptScheme::parse("php").unwrap(), CryptScheme::Php);
68    /// assert!(CryptScheme::parse("hpp").is_err());
69    /// ```
70    pub enum CryptScheme: "encryption scheme" {
71        /// Rainier's own: a self-describing, URL-safe payload naming its
72        /// algorithm and its key.
73        ///
74        /// The default, and what a new application wants — rotation and a
75        /// cipher change are both possible without re-encrypting anything.
76        #[default]
77        Native = "native",
78
79        /// The `{iv, value, mac}` envelope, for a database PHP already wrote.
80        ///
81        /// A migration position, not a destination — see the module docs.
82        Php = "php",
83    }
84}
85
86use aes::cipher::block_padding::Pkcs7;
87use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit};
88use base64::engine::general_purpose::STANDARD as B64;
89use base64::Engine as _;
90use hmac::{Hmac, Mac};
91use rainier_support::{Error, Result};
92use serde::{Deserialize, Serialize};
93use sha2::Sha256;
94use subtle::ConstantTimeEq;
95
96use crate::encrypter::Encrypter;
97use crate::key::{Key, KeyRing};
98
99type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
100type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
101type HmacSha256 = Hmac<Sha256>;
102
103/// AES-256-CBC's block and IV size.
104const IV_LEN: usize = 16;
105
106/// The JSON the PHP encrypter base64-encodes.
107#[derive(Debug, Serialize, Deserialize)]
108struct Payload {
109    iv: String,
110    value: String,
111    mac: String,
112    /// Present for the GCM variant, absent for CBC. Kept so a payload written
113    /// by a newer PHP implementation round-trips rather than being silently
114    /// dropped.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    tag: Option<String>,
117}
118
119/// The `{iv, value, mac}` envelope PHP MVC frameworks write, in Rust.
120pub struct PhpEncrypter {
121    keys: KeyRing,
122}
123
124impl PhpEncrypter {
125    /// Read and write with `keys`.
126    ///
127    /// The current key encrypts; **every** key on the ring is tried when
128    /// decrypting, because the PHP payload carries no key id and there is no
129    /// other way to support a rotation.
130    pub fn new(keys: KeyRing) -> Self {
131        Self { keys }
132    }
133
134    /// The key ring.
135    pub fn keys(&self) -> &KeyRing {
136        &self.keys
137    }
138
139    /// Encrypt under one specific key.
140    fn encrypt_with(key: &Key, plain: &[u8]) -> Result<String> {
141        let mut iv = [0u8; IV_LEN];
142        rand::Rng::fill(&mut rand::thread_rng(), &mut iv[..]);
143
144        let ciphertext = Aes256CbcEnc::new_from_slices(key.bytes(), &iv)
145            .map_err(|_| Error::internal("the key or IV is the wrong length for AES-256-CBC"))?
146            .encrypt_padded_vec_mut::<Pkcs7>(plain);
147
148        let iv = B64.encode(iv);
149        let value = B64.encode(&ciphertext);
150        let mac = Self::mac(key, &iv, &value)?;
151
152        let payload = serde_json::to_vec(&Payload { iv, value, mac, tag: None })?;
153        Ok(B64.encode(payload))
154    }
155
156    /// Decrypt under one specific key.
157    fn decrypt_with(key: &Key, payload: &Payload) -> Result<Vec<u8>> {
158        let expected = Self::mac(key, &payload.iv, &payload.value)?;
159
160        // Before decrypting, always. CBC without this is a padding oracle, and
161        // a padding oracle recovers the plaintext without the key.
162        let presented = hex_decode(&payload.mac).ok_or_else(invalid)?;
163        let expected = hex_decode(&expected).ok_or_else(invalid)?;
164
165        if !bool::from(presented.ct_eq(&expected)) {
166            return Err(invalid());
167        }
168
169        let iv = B64.decode(&payload.iv).map_err(|_| invalid())?;
170        let ciphertext = B64.decode(&payload.value).map_err(|_| invalid())?;
171
172        Aes256CbcDec::new_from_slices(key.bytes(), &iv)
173            .map_err(|_| invalid())?
174            .decrypt_padded_vec_mut::<Pkcs7>(&ciphertext)
175            .map_err(|_| invalid())
176    }
177
178    /// `hex(hmac_sha256(key, iv_base64 + value_base64))`.
179    ///
180    /// Over the **base64 forms**, concatenated. That is what PHP does, and it
181    /// is the one detail that makes an otherwise-correct reimplementation
182    /// produce MACs nothing accepts.
183    fn mac(key: &Key, iv: &str, value: &str) -> Result<String> {
184        let mut mac = HmacSha256::new_from_slice(key.bytes())
185            .map_err(|_| Error::internal("the key is the wrong length for HMAC-SHA256"))?;
186
187        mac.update(iv.as_bytes());
188        mac.update(value.as_bytes());
189
190        Ok(hex_encode(&mac.finalize().into_bytes()))
191    }
192}
193
194impl Encrypter for PhpEncrypter {
195    fn encrypt_bytes(&self, plain: &[u8]) -> Result<String> {
196        Self::encrypt_with(self.keys.current(), plain)
197    }
198
199    fn decrypt_bytes(&self, payload: &str) -> Result<Vec<u8>> {
200        let decoded = B64.decode(payload).map_err(|_| invalid())?;
201        let payload: Payload = serde_json::from_slice(&decoded).map_err(|_| invalid())?;
202
203        // Every key on the ring, current first. The PHP payload names no
204        // key, so trying them is the only way a rotation can work at all —
205        // and it is why rotating here costs a decrypt attempt per retired key
206        // rather than being free the way the native format is.
207        for key in self.keys.all() {
208            if let Ok(plain) = Self::decrypt_with(key, &payload) {
209                return Ok(plain);
210            }
211        }
212
213        Err(invalid())
214    }
215}
216
217impl std::fmt::Debug for PhpEncrypter {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.debug_struct("PhpEncrypter").field("keys", &self.keys.ids()).finish()
220    }
221}
222
223/// One error for every failure, so nothing distinguishes a bad MAC from bad
224/// padding from the wrong key.
225fn invalid() -> Error {
226    Error::internal("could not decrypt the payload")
227}
228
229fn hex_encode(bytes: &[u8]) -> String {
230    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
231}
232
233fn hex_decode(hex: &str) -> Option<Vec<u8>> {
234    if !hex.len().is_multiple_of(2) {
235        return None;
236    }
237
238    (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok()).collect()
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::encrypter::EncrypterExt;
245
246    fn encrypter() -> PhpEncrypter {
247        PhpEncrypter::new(KeyRing::new(Key::generate()))
248    }
249
250    #[test]
251    fn a_value_round_trips() {
252        let encrypter = encrypter();
253        let payload = encrypter.encrypt("a card number").unwrap();
254
255        assert_eq!(encrypter.decrypt(&payload).unwrap(), "a card number");
256    }
257
258    #[test]
259    fn the_payload_is_the_shape_php_writes() {
260        let payload = encrypter().encrypt("hello").unwrap();
261
262        let decoded = B64.decode(&payload).expect("base64 around the JSON");
263        let json: serde_json::Value = serde_json::from_slice(&decoded).expect("JSON inside");
264
265        assert!(json["iv"].is_string());
266        assert!(json["value"].is_string());
267        assert!(json["mac"].is_string());
268
269        // The IV is 16 raw bytes, base64'd — which is what a PHP reader will
270        // pass straight to openssl_decrypt.
271        assert_eq!(B64.decode(json["iv"].as_str().unwrap()).unwrap().len(), IV_LEN);
272        // And the MAC is hex-encoded SHA-256: 64 characters.
273        assert_eq!(json["mac"].as_str().unwrap().len(), 64);
274    }
275
276    #[test]
277    fn the_mac_covers_the_base64_forms() {
278        // The detail every reimplementation gets wrong. Asserted directly, so
279        // a "tidy-up" that MACs the raw bytes instead fails here rather than
280        // in production against a real PHP application.
281        let key = Key::generate();
282        let iv = B64.encode([7u8; IV_LEN]);
283        let value = B64.encode(b"ciphertext");
284
285        let mut expected = HmacSha256::new_from_slice(key.bytes()).unwrap();
286        expected.update(format!("{iv}{value}").as_bytes());
287
288        assert_eq!(
289            PhpEncrypter::mac(&key, &iv, &value).unwrap(),
290            hex_encode(&expected.finalize().into_bytes())
291        );
292    }
293
294    #[test]
295    fn a_tampered_ciphertext_is_refused_rather_than_decrypted() {
296        // The padding oracle this order of operations exists to close.
297        let encrypter = encrypter();
298        let payload = encrypter.encrypt("a card number").unwrap();
299
300        let decoded = B64.decode(&payload).unwrap();
301        let mut json: Payload = serde_json::from_slice(&decoded).unwrap();
302
303        // Flip a byte of the ciphertext, leaving the MAC alone.
304        let mut ciphertext = B64.decode(&json.value).unwrap();
305        ciphertext[0] ^= 0xff;
306        json.value = B64.encode(&ciphertext);
307
308        let tampered = B64.encode(serde_json::to_vec(&json).unwrap());
309
310        assert!(encrypter.decrypt(&tampered).is_err());
311    }
312
313    #[test]
314    fn a_tampered_iv_is_refused() {
315        let encrypter = encrypter();
316        let payload = encrypter.encrypt("a card number").unwrap();
317
318        let decoded = B64.decode(&payload).unwrap();
319        let mut json: Payload = serde_json::from_slice(&decoded).unwrap();
320
321        let mut iv = B64.decode(&json.iv).unwrap();
322        iv[0] ^= 0xff;
323        json.iv = B64.encode(&iv);
324
325        let tampered = B64.encode(serde_json::to_vec(&json).unwrap());
326
327        assert!(encrypter.decrypt(&tampered).is_err());
328    }
329
330    #[test]
331    fn another_applications_payload_does_not_decrypt() {
332        let payload = encrypter().encrypt("a card number").unwrap();
333
334        assert!(encrypter().decrypt(&payload).is_err());
335    }
336
337    #[test]
338    fn a_retired_key_still_reads_what_it_wrote() {
339        // The PHP payload names no key, so this is a decrypt attempt per
340        // retired key rather than a lookup — worth knowing before keeping ten
341        // of them on the ring.
342        let old = Key::generate();
343        let payload = PhpEncrypter::new(KeyRing::new(old.clone())).encrypt("secret").unwrap();
344
345        let rotated = PhpEncrypter::new(KeyRing::new(Key::generate()).with_previous(old));
346        assert_eq!(rotated.decrypt(&payload).unwrap(), "secret");
347    }
348
349    #[test]
350    fn nonsense_is_an_error_rather_than_a_panic() {
351        let encrypter = encrypter();
352
353        for payload in ["", "not base64 at all !!", &B64.encode("not json"), &B64.encode("{}")] {
354            assert!(encrypter.decrypt(payload).is_err(), "{payload:?}");
355        }
356    }
357
358    #[test]
359    fn every_failure_looks_the_same() {
360        // A bad MAC, bad padding and the wrong key must be indistinguishable:
361        // telling them apart is what a padding oracle is built from.
362        let encrypter = encrypter();
363        let payload = encrypter.encrypt("secret").unwrap();
364
365        let corrupt = B64.encode("{}");
366        let wrong_key =
367            PhpEncrypter::new(KeyRing::new(Key::generate())).encrypt("secret").unwrap();
368        let _ = payload;
369
370        assert_eq!(
371            encrypter.decrypt(&corrupt).unwrap_err().message(),
372            encrypter.decrypt(&wrong_key).unwrap_err().message()
373        );
374    }
375
376    #[test]
377    fn empty_and_long_values_round_trip() {
378        let encrypter = encrypter();
379
380        for plain in ["", "a", &"x".repeat(10_000), "héllo — ünicode"] {
381            assert_eq!(encrypter.decrypt(&encrypter.encrypt(plain).unwrap()).unwrap(), plain);
382        }
383    }
384
385    #[test]
386    fn hex_round_trips() {
387        assert_eq!(hex_decode(&hex_encode(&[0, 15, 16, 255])), Some(vec![0, 15, 16, 255]));
388        assert_eq!(hex_decode("abc"), None, "an odd length is not hex");
389        assert_eq!(hex_decode("zz"), None);
390    }
391
392    /// A key and IV of known bytes, so the ciphertext is deterministic.
393    fn fixed_key() -> Key {
394        Key::from_base64("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=").unwrap()
395    }
396
397    #[test]
398    fn the_ciphertext_is_pinned_to_a_known_vector() {
399        // AES-256-CBC over a fixed key and IV, with PKCS#7 padding. Frozen so
400        // a change to the padding, the mode or the key handling fails here
401        // rather than silently making every existing row unreadable.
402        //
403        // Both values below were cross-checked against an independent
404        // implementation (Python's `cryptography` for the AES-256-CBC/PKCS#7
405        // ciphertext, `hmac`+`hashlib` for the tag) rather than being read
406        // back out of this code — so they pin interoperability and not merely
407        // "whatever it does today".
408        let key = fixed_key();
409        let iv = B64.encode([0u8; IV_LEN]);
410
411        let ciphertext = Aes256CbcEnc::new_from_slices(key.bytes(), &[0u8; IV_LEN])
412            .unwrap()
413            .encrypt_padded_vec_mut::<Pkcs7>(b"a card number");
414
415        assert_eq!(B64.encode(&ciphertext), "nH4xXUTQpgBTKXvgzml9eg==");
416
417        // And the MAC over the two base64 strings, which is the part a PHP
418        // reader recomputes.
419        assert_eq!(
420            PhpEncrypter::mac(&key, &iv, &B64.encode(&ciphertext)).unwrap(),
421            "f648b5eac55d1dd21fe92175da4660271c245c3c0d23a9839e932eb583716aad"
422        );
423    }
424}