rws_server/cookie/crypto_ext.rs
1//! Cookie signing (HMAC-SHA256) and encryption (AES-256-GCM), gated behind
2//! the `crypto` Cargo feature (the same feature that provides
3//! [`crate::crypto::hash_password`] and [`crate::crypto::generate_token`]).
4//!
5//! Plain [`super::SetCookie`] values are readable and tamperable by the
6//! client — fine for a theme preference, wrong for a session token or any
7//! value a handler trusts without re-verifying server-side.
8//!
9//! - [`signed_cookie`] / [`verify_signed_cookie`] — the value stays
10//! plain-text and readable by the client, but any tampering (to the value
11//! or the signature) is detected. Use when the client only needs to see
12//! the value, not modify it undetected — e.g. a discount code or a
13//! feature-flag override the client shouldn't be able to forge.
14//! - [`encrypted_cookie`] / [`decrypt_cookie`] — the value is neither
15//! readable nor tamperable by the client. Use for anything the client
16//! shouldn't see at all, e.g. a serialized session token.
17//!
18//! ```rust
19//! # extern crate rws_server as rust_web_server;
20//! use rust_web_server::cookie::{signed_cookie, verify_signed_cookie, SetCookie};
21//!
22//! let secret = b"my-signing-secret";
23//! let cookie_value = signed_cookie("plan=pro", secret);
24//! let header_value = SetCookie::new("prefs", &cookie_value).http_only().build();
25//!
26//! // ... later, reading the incoming Cookie header ...
27//! assert_eq!(Some("plan=pro".to_string()), verify_signed_cookie(&cookie_value, secret));
28//! assert_eq!(None, verify_signed_cookie(&cookie_value, b"wrong-secret"));
29//! ```
30
31#[cfg(test)]
32mod tests;
33
34use aes_gcm::aead::{Aead, KeyInit};
35use aes_gcm::{Aes256Gcm, Nonce};
36use hmac::{Hmac, Mac};
37use rand_core::{OsRng, RngCore};
38use sha2::{Digest, Sha256};
39
40type HmacSha256 = Hmac<Sha256>;
41
42/// Signs `value` with HMAC-SHA256 keyed by `secret`. Returns
43/// `"<value>.<hex-signature>"` — store the result directly as a cookie value
44/// (e.g. `SetCookie::new("prefs", signed_cookie(value, secret))`).
45///
46/// The value itself remains plain-text and readable by the client; only
47/// tampering is detected, by [`verify_signed_cookie`].
48pub fn signed_cookie(value: &str, secret: &[u8]) -> String {
49 let sig = hmac_sha256(secret, value.as_bytes());
50 format!("{}.{}", value, to_hex(&sig))
51}
52
53/// Verifies a cookie value produced by [`signed_cookie`]. Returns the
54/// original value on success. Returns `None` on any failure — missing
55/// separator, malformed hex, or a signature mismatch (tampering, or the
56/// wrong `secret`).
57///
58/// Splits on the *last* `.` so a `value` that itself contains dots is
59/// handled correctly — the hex-encoded HMAC signature never contains one.
60pub fn verify_signed_cookie(signed: &str, secret: &[u8]) -> Option<String> {
61 let (value, sig_hex) = signed.rsplit_once('.')?;
62 let given_sig = from_hex(sig_hex)?;
63 let mut mac = <HmacSha256 as Mac>::new_from_slice(secret).ok()?;
64 mac.update(value.as_bytes());
65 mac.verify_slice(&given_sig).ok()?;
66 Some(value.to_string())
67}
68
69/// Encrypts `value` with AES-256-GCM. `key` may be any length — it's hashed
70/// with SHA-256 first to produce the required 256-bit key, the same
71/// convenience [`signed_cookie`]'s HMAC secret already offers. A fresh
72/// random 96-bit nonce is generated on every call. Returns
73/// `"<hex-nonce>.<hex-ciphertext-and-tag>"` — store the result directly as a
74/// cookie value.
75///
76/// Unlike [`signed_cookie`], the value is not readable by the client at all,
77/// not just tamper-evident.
78pub fn encrypted_cookie(value: &str, key: &[u8]) -> String {
79 let cipher = Aes256Gcm::new_from_slice(&Sha256::digest(key))
80 .expect("SHA-256 digest is always 32 bytes, the size AES-256-GCM requires");
81
82 let mut nonce_bytes = [0u8; 12];
83 OsRng.fill_bytes(&mut nonce_bytes);
84 let nonce = Nonce::from(nonce_bytes);
85
86 let ciphertext = cipher
87 .encrypt(&nonce, value.as_bytes())
88 .expect("AES-256-GCM encryption of a cookie-sized value cannot fail");
89
90 format!("{}.{}", to_hex(&nonce_bytes), to_hex(&ciphertext))
91}
92
93/// Decrypts a cookie value produced by [`encrypted_cookie`]. Returns the
94/// original value on success. Returns `None` on any failure — malformed
95/// input, the wrong `key`, or a tampered/corrupted ciphertext (GCM's
96/// authentication tag check fails closed rather than returning garbage).
97pub fn decrypt_cookie(encrypted: &str, key: &[u8]) -> Option<String> {
98 let (nonce_hex, ciphertext_hex) = encrypted.split_once('.')?;
99 let nonce_bytes = from_hex(nonce_hex)?;
100 if nonce_bytes.len() != 12 {
101 return None;
102 }
103 let ciphertext = from_hex(ciphertext_hex)?;
104
105 let cipher = Aes256Gcm::new_from_slice(&Sha256::digest(key)).ok()?;
106 let nonce_bytes: [u8; 12] = nonce_bytes.as_slice().try_into().ok()?;
107 let nonce = Nonce::from(nonce_bytes);
108 let plaintext = cipher.decrypt(&nonce, ciphertext.as_ref()).ok()?;
109 String::from_utf8(plaintext).ok()
110}
111
112fn hmac_sha256(secret: &[u8], message: &[u8]) -> Vec<u8> {
113 let mut mac = <HmacSha256 as Mac>::new_from_slice(secret).expect("HMAC accepts any key size");
114 mac.update(message);
115 mac.finalize().into_bytes().to_vec()
116}
117
118fn to_hex(bytes: &[u8]) -> String {
119 bytes.iter().map(|b| format!("{:02x}", b)).collect()
120}
121
122fn from_hex(s: &str) -> Option<Vec<u8>> {
123 if s.is_empty() || s.len() % 2 != 0 {
124 return None;
125 }
126 (0..s.len())
127 .step_by(2)
128 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
129 .collect()
130}