field.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. //! Versioned AES-256-GCM codec for PII field values at rest.
  2. //!
  3. //! Fields (emails, client names, redirect URIs, mTLS identities) are sealed
  4. //! into an opaque blob and only the ciphertext is stored. The AEAD key is
  5. //! derived per key-ring generation from the PII-encryption key via HKDF-SHA256
  6. //! (RFC 5869, empty salt, info `pii-field-gcm-v1`), so the field codec is
  7. //! domain-separated from the TOTP-encryption use of its own key chain.
  8. //!
  9. //! Blob layout:
  10. //!
  11. //! ```text
  12. //! version(1)=1 || kid_len(1) || kid || nonce(12) || AES-256-GCM ct+tag
  13. //! ```
  14. //!
  15. //! The reader resolves the named `kid` through [`KeyRing::pii_key_by_id`], so a
  16. //! retained key keeps opening values written before a rotation. Every failure
  17. //! (unknown kid, malformed blob, tampered ciphertext, bad tag) collapses to the
  18. //! same opaque [`FieldError::Decrypt`]: the reason is never distinguished and no
  19. //! plaintext or key material ever reaches a log or an error message.
  20. //!
  21. //! Transient derived key bytes are wrapped in [`zeroize::Zeroizing`] and wiped
  22. //! when they leave scope.
  23. //!
  24. //! The same PII key chain feeds a keyed **blind index** for equality lookups.
  25. //! [`blind_index_active`] and [`blind_index_candidates`] derive an HMAC-SHA256
  26. //! key under a distinct HKDF info label (`pii-blind-index-v1`) and format each
  27. //! entry as `"{kid}:{lowercase_hex_digest}"`. The index never exposes the
  28. //! plaintext, and its HMAC key is cryptographically independent of the
  29. //! field-encryption key.
  30. use ring::{
  31. aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
  32. hkdf, hmac,
  33. rand::{SecureRandom as _, SystemRandom},
  34. };
  35. use secrecy::ExposeSecret as _;
  36. use zeroize::Zeroizing;
  37. use crate::keys::KeyRing;
  38. /// Current field-blob format version.
  39. const FIELD_BLOB_VERSION: u8 = 1;
  40. /// HKDF domain-separation label for the PII field-encryption key.
  41. const HKDF_INFO: &[u8] = b"pii-field-gcm-v1";
  42. /// HKDF domain-separation label for the PII blind-index HMAC key.
  43. const BLIND_INDEX_INFO: &[u8] = b"pii-blind-index-v1";
  44. /// AES-GCM nonce length in bytes.
  45. const NONCE_LEN: usize = 12;
  46. /// Maximum key-id length encodable by the one-byte length prefix.
  47. const MAX_KID_LEN: usize = u8::MAX as usize;
  48. /// Failures sealing or opening a PII field.
  49. ///
  50. /// Every variant carries a static, payload-free message: no plaintext, token,
  51. /// or key material can appear in a log, a metric, or a `Debug` rendering.
  52. #[derive(Debug, thiserror::Error)]
  53. pub enum FieldError {
  54. /// No PII key is configured, or the AEAD rejected the input.
  55. #[error("PII field encryption failed")]
  56. Encrypt,
  57. /// The blob is malformed, names an unknown key, or fails authentication.
  58. #[error("PII field cannot be decrypted")]
  59. Decrypt,
  60. /// The operating-system random number generator failed.
  61. #[error("operating-system randomness unavailable")]
  62. UnavailableRandomness,
  63. }
  64. /// Seal a UTF-8 string under the active PII key.
  65. ///
  66. /// # Errors
  67. ///
  68. /// Returns [`FieldError::Encrypt`] when no PII key is configured or the AEAD
  69. /// rejects the input, or [`FieldError::UnavailableRandomness`] when the
  70. /// operating-system RNG fails.
  71. pub fn seal_text(keys: &KeyRing, plaintext: &str) -> Result<Vec<u8>, FieldError> {
  72. seal_bytes(keys, plaintext.as_bytes())
  73. }
  74. /// Seal a JSON-serializable value under the active PII key.
  75. ///
  76. /// # Errors
  77. ///
  78. /// Returns [`FieldError::Encrypt`] when the value cannot be serialized, no PII
  79. /// key is configured, or the AEAD rejects the input; see also
  80. /// [`FieldError::UnavailableRandomness`].
  81. pub fn seal_json<T: serde::Serialize>(keys: &KeyRing, value: &T) -> Result<Vec<u8>, FieldError> {
  82. let encoded = serde_json::to_vec(value).map_err(|_| FieldError::Encrypt)?;
  83. seal_bytes(keys, &encoded)
  84. }
  85. /// Seal raw bytes under the active PII key.
  86. ///
  87. /// Blob layout: `version(1)=1 || kid_len(1) || kid || nonce(12) || ct+tag`.
  88. ///
  89. /// # Errors
  90. ///
  91. /// Returns [`FieldError::Encrypt`] when no PII key is configured or the AEAD
  92. /// rejects the input, or [`FieldError::UnavailableRandomness`] when the
  93. /// operating-system RNG fails.
  94. pub fn seal_bytes(keys: &KeyRing, plaintext: &[u8]) -> Result<Vec<u8>, FieldError> {
  95. let (key_id, secret) = keys.active_pii_key().ok_or(FieldError::Encrypt)?;
  96. let kid = key_id.as_str().as_bytes();
  97. if kid.is_empty() || kid.len() > MAX_KID_LEN {
  98. return Err(FieldError::Encrypt);
  99. }
  100. let key =
  101. Zeroizing::new(derive_key(secret.expose_secret().as_bytes()).map_err(|_| FieldError::Encrypt)?);
  102. let mut nonce_bytes = [0u8; NONCE_LEN];
  103. SystemRandom::new()
  104. .fill(&mut nonce_bytes)
  105. .map_err(|_| FieldError::UnavailableRandomness)?;
  106. let sealing =
  107. LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &key[..]).map_err(|_| FieldError::Encrypt)?);
  108. let mut in_out = plaintext.to_vec();
  109. sealing
  110. .seal_in_place_append_tag(
  111. Nonce::assume_unique_for_key(nonce_bytes),
  112. Aad::empty(),
  113. &mut in_out,
  114. )
  115. .map_err(|_| FieldError::Encrypt)?;
  116. let mut blob = Vec::with_capacity(2 + kid.len() + NONCE_LEN + in_out.len());
  117. blob.push(FIELD_BLOB_VERSION);
  118. blob.push(kid.len() as u8);
  119. blob.extend_from_slice(kid);
  120. blob.extend_from_slice(&nonce_bytes);
  121. blob.extend_from_slice(&in_out);
  122. Ok(blob)
  123. }
  124. /// Open a sealed UTF-8 string, resolving the key named in the blob.
  125. ///
  126. /// # Errors
  127. ///
  128. /// Returns [`FieldError::Decrypt`] for a malformed blob, an unknown key id, a
  129. /// non-UTF-8 plaintext, or an authentication failure. The reason is never
  130. /// distinguished.
  131. pub fn open_text(keys: &KeyRing, blob: &[u8]) -> Result<String, FieldError> {
  132. String::from_utf8(open_bytes(keys, blob)?).map_err(|_| FieldError::Decrypt)
  133. }
  134. /// Open a sealed JSON value, resolving the key named in the blob.
  135. ///
  136. /// # Errors
  137. ///
  138. /// Returns [`FieldError::Decrypt`] for a malformed blob, an unknown key id, an
  139. /// authentication failure, or a value that is not valid JSON for `T`. The
  140. /// reason is never distinguished.
  141. pub fn open_json<T: serde::de::DeserializeOwned>(
  142. keys: &KeyRing,
  143. blob: &[u8],
  144. ) -> Result<T, FieldError> {
  145. serde_json::from_slice(&open_bytes(keys, blob)?).map_err(|_| FieldError::Decrypt)
  146. }
  147. /// Open sealed raw bytes, resolving the key named in the blob.
  148. ///
  149. /// # Errors
  150. ///
  151. /// Returns [`FieldError::Decrypt`] for a malformed blob, an unknown key id, or
  152. /// an authentication failure. The reason is never distinguished.
  153. pub fn open_bytes(keys: &KeyRing, blob: &[u8]) -> Result<Vec<u8>, FieldError> {
  154. if blob.len() < 2 + NONCE_LEN || blob[0] != FIELD_BLOB_VERSION {
  155. return Err(FieldError::Decrypt);
  156. }
  157. let kid_len = usize::from(blob[1]);
  158. let header = 2 + kid_len;
  159. if blob.len() < header + NONCE_LEN {
  160. return Err(FieldError::Decrypt);
  161. }
  162. let kid = core::str::from_utf8(blob.get(2..header).unwrap_or_default())
  163. .map_err(|_| FieldError::Decrypt)?;
  164. let secret = keys.pii_key_by_id(kid).ok_or(FieldError::Decrypt)?;
  165. let key =
  166. Zeroizing::new(derive_key(secret.expose_secret().as_bytes()).map_err(|_| FieldError::Decrypt)?);
  167. let mut nonce_bytes = [0u8; NONCE_LEN];
  168. nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
  169. let ciphertext = &blob[header + NONCE_LEN..];
  170. let mut in_out = ciphertext.to_vec();
  171. let opening =
  172. LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &key[..]).map_err(|_| FieldError::Decrypt)?);
  173. let plaintext = opening
  174. .open_in_place(
  175. Nonce::assume_unique_for_key(nonce_bytes),
  176. Aad::empty(),
  177. &mut in_out,
  178. )
  179. .map_err(|_| FieldError::Decrypt)?;
  180. Ok(plaintext.to_vec())
  181. }
  182. /// Derive a 256-bit AES key from PII key material via HKDF-SHA256 (RFC 5869,
  183. /// empty salt, info `pii-field-gcm-v1`).
  184. fn derive_key(pii_key: &[u8]) -> Result<[u8; 32], ring::error::Unspecified> {
  185. let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]).extract(pii_key);
  186. let okm = prk.expand(&[HKDF_INFO], HkdfAesKey)?;
  187. let mut key = [0u8; 32];
  188. okm.fill(&mut key)?;
  189. Ok(key)
  190. }
  191. /// `ring::hkdf` output-length marker for a 256-bit AES key.
  192. struct HkdfAesKey;
  193. impl hkdf::KeyType for HkdfAesKey {
  194. fn len(&self) -> usize {
  195. 32
  196. }
  197. }
  198. /// `ring::hkdf` output-length marker for the 256-bit blind-index HMAC key.
  199. struct HkdfIndexKey;
  200. impl hkdf::KeyType for HkdfIndexKey {
  201. fn len(&self) -> usize {
  202. 32
  203. }
  204. }
  205. /// Compute the active blind index for a canonicalized value.
  206. ///
  207. /// The caller MUST pass the canonical form (for example
  208. /// [`crate::db::user_repo::normalize_email`] for emails, or the exact stored
  209. /// string for redirect URIs and mTLS identities); this function never
  210. /// normalizes. The result is `"{kid}:{lowercase_hex_hmac_sha256}"`, where `kid`
  211. /// is the active PII key id.
  212. ///
  213. /// # Key generation
  214. ///
  215. /// The HMAC key is derived from one PII key generation, so a value's index is
  216. /// stable only within that generation: two logically equal values indexed under
  217. /// different keys have different stored indexes. Reads must therefore query
  218. /// [`blind_index_candidates`], which spans every retained generation. The
  219. /// `pii rotate` command re-indexes every row and runs under a write freeze.
  220. #[must_use]
  221. pub fn blind_index_active(keys: &KeyRing, normalized: &str) -> String {
  222. // A `KeyRing` is only built with a non-empty PII chain (`from_parts` rejects
  223. // an empty one), so the active key always exists.
  224. keys
  225. .active_pii_key()
  226. .map(|(key_id, secret)| {
  227. index_value(
  228. key_id.as_str(),
  229. secret.expose_secret().as_bytes(),
  230. normalized,
  231. )
  232. })
  233. .unwrap_or_default()
  234. }
  235. /// Compute the blind index under every retained PII key, newest-first.
  236. ///
  237. /// The first entry equals [`blind_index_active`]. Reads match a stored index
  238. /// against this whole list so a value written before a rotation is still found
  239. /// while the old key is retained. The list length equals the PII key chain
  240. /// length.
  241. #[must_use]
  242. pub fn blind_index_candidates(keys: &KeyRing, normalized: &str) -> Vec<String> {
  243. keys
  244. .pii_keys_newest_first()
  245. .map(|(key_id, secret)| {
  246. index_value(
  247. key_id.as_str(),
  248. secret.expose_secret().as_bytes(),
  249. normalized,
  250. )
  251. })
  252. .collect()
  253. }
  254. /// Format `"{kid}:{lowercase_hex_hmac_sha256}"` for one PII key generation.
  255. fn index_value(kid: &str, pii_key: &[u8], normalized: &str) -> String {
  256. let key = derive_index_key(pii_key);
  257. let mac_key = hmac::Key::new(hmac::HMAC_SHA256, &key[..]);
  258. let tag = hmac::sign(&mac_key, normalized.as_bytes());
  259. format!("{kid}:{}", hex_lower(tag.as_ref()))
  260. }
  261. /// Derive the 256-bit blind-index HMAC key via HKDF-SHA256 (RFC 5869, empty
  262. /// salt, info `pii-blind-index-v1`).
  263. ///
  264. /// A 32-byte expand/fill for SHA-256 cannot fail; a failure leaves the key
  265. /// zeroed rather than panicking in a request path. The returned bytes are wiped
  266. /// on drop.
  267. fn derive_index_key(pii_key: &[u8]) -> Zeroizing<[u8; 32]> {
  268. let mut key = Zeroizing::new([0u8; 32]);
  269. let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]).extract(pii_key);
  270. if let Ok(okm) = prk.expand(&[BLIND_INDEX_INFO], HkdfIndexKey) {
  271. let _ = okm.fill(&mut key[..]);
  272. }
  273. key
  274. }
  275. /// Lowercase hexadecimal encoding.
  276. fn hex_lower(bytes: &[u8]) -> String {
  277. const HEX: &[u8; 16] = b"0123456789abcdef";
  278. let mut out = String::with_capacity(bytes.len() * 2);
  279. for byte in bytes {
  280. out.push(char::from(HEX[usize::from(byte >> 4)]));
  281. out.push(char::from(HEX[usize::from(byte & 0x0f)]));
  282. }
  283. out
  284. }
  285. #[cfg(test)]
  286. mod tests {
  287. use secrecy::SecretString;
  288. use super::{
  289. FieldError, blind_index_active, blind_index_candidates, open_bytes, open_json, open_text,
  290. seal_bytes, seal_json, seal_text,
  291. };
  292. use crate::keys::{KeyId, KeyRing};
  293. /// Test key ring with one pepper, one TOTP key, and one PII key generation.
  294. fn test_ring() -> Result<KeyRing, FieldError> {
  295. KeyRing::from_parts(
  296. vec![(
  297. KeyId::new("v1"),
  298. SecretString::from("test-pepper-0123456789abcdef"),
  299. )],
  300. vec![(
  301. KeyId::new("v1"),
  302. SecretString::from("test-totp-key-0123456789abcdef"),
  303. )],
  304. vec![(
  305. KeyId::new("v1"),
  306. SecretString::from("test-pii-key-0123456789abcdef"),
  307. )],
  308. )
  309. .map_err(|_| FieldError::Encrypt)
  310. }
  311. fn ring_with_pii(pii: Vec<(&str, &str)>) -> Result<KeyRing, FieldError> {
  312. let pii_keys = pii
  313. .into_iter()
  314. .map(|(id, value)| (KeyId::new(id), SecretString::from(value)))
  315. .collect();
  316. KeyRing::from_parts(
  317. vec![(
  318. KeyId::new("v1"),
  319. SecretString::from("test-pepper-0123456789abcdef"),
  320. )],
  321. vec![(
  322. KeyId::new("v1"),
  323. SecretString::from("test-totp-key-0123456789abcdef"),
  324. )],
  325. pii_keys,
  326. )
  327. .map_err(|_| FieldError::Encrypt)
  328. }
  329. #[test]
  330. fn blind_index_is_deterministic() -> Result<(), FieldError> {
  331. let keys = test_ring()?;
  332. let first = blind_index_active(&keys, "alice@example.com");
  333. let second = blind_index_active(&keys, "alice@example.com");
  334. assert_eq!(first, second);
  335. // Format is `"{kid}:{lowercase_hex_hmac_sha256}"`.
  336. let (kid, digest) = first.split_once(':').ok_or(FieldError::Encrypt)?;
  337. assert_eq!(kid, "v1");
  338. assert_eq!(digest.len(), 64);
  339. assert!(
  340. digest
  341. .bytes()
  342. .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')),
  343. "digest must be lowercase hex: {digest}"
  344. );
  345. assert_ne!(
  346. blind_index_active(&keys, "alice@example.com"),
  347. blind_index_active(&keys, "bob@example.com")
  348. );
  349. // Two different PII keys produce different indexes for the same input,
  350. // even under the same key id: the HMAC key, not just the kid, is bound.
  351. let other = ring_with_pii(vec![("v1", "test-pii-key-fedcba9876543210")])?;
  352. assert_ne!(
  353. blind_index_active(&keys, "alice@example.com"),
  354. blind_index_active(&other, "alice@example.com")
  355. );
  356. Ok(())
  357. }
  358. #[test]
  359. fn blind_index_candidates_order_newest_first() -> Result<(), FieldError> {
  360. let keys = ring_with_pii(vec![
  361. ("v2", "test-pii-key-new-0123456789abcdef"),
  362. ("v1", "test-pii-key-old-0123456789abcdef"),
  363. ])?;
  364. let candidates = blind_index_candidates(&keys, "alice@example.com");
  365. assert_eq!(candidates.len(), 2);
  366. assert!(candidates[0].starts_with("v2:"));
  367. assert!(candidates[1].starts_with("v1:"));
  368. assert_ne!(candidates[0], candidates[1]);
  369. assert_eq!(
  370. Some(blind_index_active(&keys, "alice@example.com")),
  371. candidates.first().cloned()
  372. );
  373. Ok(())
  374. }
  375. #[test]
  376. fn field_seal_open_round_trip() -> Result<(), FieldError> {
  377. let keys = test_ring()?;
  378. let plaintext = "alice@example.com";
  379. let sealed = seal_text(&keys, plaintext)?;
  380. // Blob layout: version=1, kid_len=2, kid="v1", nonce(12), ct+tag.
  381. assert_eq!(sealed[0], 1);
  382. assert_eq!(usize::from(sealed[1]), 2);
  383. assert_eq!(&sealed[2..4], b"v1");
  384. assert!(sealed.len() > 2 + 2 + 12 + 16);
  385. // A sealed UTF-8 value must not contain the plaintext substring.
  386. assert!(
  387. !sealed
  388. .windows(plaintext.len())
  389. .any(|window| window == plaintext.as_bytes())
  390. );
  391. assert_eq!(open_text(&keys, &sealed)?, plaintext);
  392. // A JSON value round-trips through the same blob codec.
  393. let value = serde_json::json!({ "email": "alice@example.com", "age": 30 });
  394. let sealed_json = seal_json(&keys, &value)?;
  395. let restored: serde_json::Value = open_json(&keys, &sealed_json)?;
  396. assert_eq!(restored, value);
  397. // Raw bytes round-trip unchanged.
  398. let bytes = [0u8, 255, 1, 2, 3];
  399. let sealed_bytes = seal_bytes(&keys, &bytes)?;
  400. assert_eq!(open_bytes(&keys, &sealed_bytes)?, bytes);
  401. Ok(())
  402. }
  403. #[test]
  404. fn field_rejects_tampered_blob() -> Result<(), FieldError> {
  405. let keys = test_ring()?;
  406. let sealed = seal_text(&keys, "secret@example.com")?;
  407. // Flipping one ciphertext byte must fail authentication.
  408. let mut tampered = sealed.clone();
  409. let last = tampered.len() - 1;
  410. tampered[last] ^= 0x01;
  411. assert!(matches!(
  412. open_text(&keys, &tampered),
  413. Err(FieldError::Decrypt)
  414. ));
  415. // A truncated blob fails closed too, without panicking.
  416. assert!(matches!(
  417. open_text(&keys, &sealed[..5]),
  418. Err(FieldError::Decrypt)
  419. ));
  420. Ok(())
  421. }
  422. #[test]
  423. fn field_rejects_unknown_kid() -> Result<(), FieldError> {
  424. let keys = test_ring()?;
  425. let mut sealed = seal_text(&keys, "secret@example.com")?;
  426. // Overwrite the kid with a same-length id that is not in the ring.
  427. assert_eq!(usize::from(sealed[1]), 2);
  428. sealed[2] = b'v';
  429. sealed[3] = b'9';
  430. assert!(matches!(
  431. open_text(&keys, &sealed),
  432. Err(FieldError::Decrypt)
  433. ));
  434. Ok(())
  435. }
  436. }