Skip to main content

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}