Skip to main content

rok_encrypt/
lib.rs

1mod config;
2mod error;
3mod signer;
4
5pub use config::EncryptConfig;
6pub use error::EncryptError;
7pub use signer::Signer;
8
9use std::time::Duration;
10
11use aes_gcm::{
12    aead::{Aead, KeyInit},
13    Aes256Gcm, Key, Nonce,
14};
15use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19// ── internal token payload ────────────────────────────────────────────────────
20
21#[derive(Serialize, Deserialize)]
22struct Payload {
23    /// The user-supplied value.
24    v: String,
25    /// Optional purpose tag — set by [`Encrypter::seal_for`].
26    #[serde(skip_serializing_if = "Option::is_none")]
27    p: Option<String>,
28    /// Optional Unix expiry timestamp — set by [`Encrypter::seal_expiring`].
29    #[serde(skip_serializing_if = "Option::is_none")]
30    e: Option<i64>,
31}
32
33// ── key derivation ────────────────────────────────────────────────────────────
34
35fn derive_key(secret: &str) -> [u8; 32] {
36    Sha256::digest(secret.as_bytes()).into()
37}
38
39// ── Encrypter ─────────────────────────────────────────────────────────────────
40
41/// AES-256-GCM encryption façade with purpose-binding, token expiry, and key
42/// rotation.
43///
44/// # Token format
45///
46/// Tokens are URL-safe base64 strings: `base64url(nonce[12] || ciphertext)`.
47/// The plaintext is a compact JSON payload containing the value and optional
48/// metadata.
49///
50/// # Example
51///
52/// ```rust,ignore
53/// use std::time::Duration;
54/// use rok_encrypt::{EncryptConfig, Encrypter};
55///
56/// let enc = Encrypter::from_config(EncryptConfig::new("my-app-secret"));
57///
58/// // Basic round-trip
59/// let token = enc.seal("hello");
60/// assert_eq!(enc.open(&token).unwrap(), "hello");
61///
62/// // Purpose-bound (e.g. password-reset tokens)
63/// let token = enc.seal_for("pw-reset", "user@example.com");
64/// assert!(enc.open_for("pw-reset", &token).is_ok());
65/// assert!(enc.open_for("invite",   &token).is_err()); // wrong purpose
66///
67/// // Expiring token
68/// let token = enc.seal_expiring("data", Duration::from_secs(3600));
69/// assert!(enc.open(&token).is_ok());
70/// ```
71#[derive(Clone)]
72pub struct Encrypter {
73    primary_key: [u8; 32],
74    old_keys: Vec<[u8; 32]>,
75}
76
77impl Encrypter {
78    /// Build an `Encrypter` from `config`.
79    pub fn from_config(config: EncryptConfig) -> Self {
80        Self {
81            primary_key: derive_key(&config.key),
82            old_keys: config.old_keys.iter().map(|k| derive_key(k)).collect(),
83        }
84    }
85
86    // ── internal helpers ──────────────────────────────────────────────────────
87
88    fn cipher(key: &[u8; 32]) -> Aes256Gcm {
89        Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key))
90    }
91
92    fn encrypt_payload(&self, payload: &Payload) -> String {
93        let json = serde_json::to_vec(payload).expect("Payload is always serialisable");
94        let nonce_bytes: [u8; 12] = rand::random();
95        let nonce = Nonce::from_slice(&nonce_bytes);
96        let mut ct = Self::cipher(&self.primary_key)
97            .encrypt(nonce, json.as_slice())
98            .expect("AES-256-GCM encryption is infallible for valid keys");
99        let mut out = nonce_bytes.to_vec();
100        out.append(&mut ct);
101        URL_SAFE_NO_PAD.encode(out)
102    }
103
104    fn decrypt_token(&self, token: &str) -> Result<Payload, EncryptError> {
105        let bytes = URL_SAFE_NO_PAD
106            .decode(token)
107            .map_err(|_| EncryptError::InvalidFormat)?;
108        if bytes.len() <= 12 {
109            return Err(EncryptError::InvalidFormat);
110        }
111        let (nonce_bytes, ciphertext) = bytes.split_at(12);
112        let nonce = Nonce::from_slice(nonce_bytes);
113
114        // Try primary key, then old keys (key rotation).
115        let keys = std::iter::once(&self.primary_key).chain(self.old_keys.iter());
116        for key in keys {
117            if let Ok(plaintext) = Self::cipher(key).decrypt(nonce, ciphertext) {
118                return serde_json::from_slice(&plaintext).map_err(|_| EncryptError::InvalidFormat);
119            }
120        }
121        Err(EncryptError::DecryptionFailed)
122    }
123
124    fn check_expiry(payload: &Payload) -> Result<(), EncryptError> {
125        if let Some(exp) = payload.e {
126            if chrono::Utc::now().timestamp() > exp {
127                return Err(EncryptError::Expired);
128            }
129        }
130        Ok(())
131    }
132
133    // ── public API ────────────────────────────────────────────────────────────
134
135    /// Encrypt `value` and return a self-contained token.
136    pub fn seal(&self, value: &str) -> String {
137        self.encrypt_payload(&Payload {
138            v: value.to_string(),
139            p: None,
140            e: None,
141        })
142    }
143
144    /// Decrypt `token` and return the original value.
145    ///
146    /// Returns `Err(Expired)` if the token carries an expiry that has passed.
147    pub fn open(&self, token: &str) -> Result<String, EncryptError> {
148        let payload = self.decrypt_token(token)?;
149        Self::check_expiry(&payload)?;
150        Ok(payload.v)
151    }
152
153    /// Like [`open`](Self::open) but returns `None` instead of an error.
154    pub fn try_open(&self, token: &str) -> Option<String> {
155        self.open(token).ok()
156    }
157
158    // ── purpose-bound ─────────────────────────────────────────────────────────
159
160    /// Encrypt `value` bound to `purpose`.
161    ///
162    /// The resulting token can only be opened with the same `purpose` via
163    /// [`open_for`](Self::open_for).
164    pub fn seal_for(&self, purpose: &str, value: &str) -> String {
165        self.encrypt_payload(&Payload {
166            v: value.to_string(),
167            p: Some(purpose.to_string()),
168            e: None,
169        })
170    }
171
172    /// Decrypt a purpose-bound token, verifying that its purpose matches
173    /// `expected_purpose`.
174    ///
175    /// Returns `Err(WrongPurpose)` on a mismatch, `Err(Expired)` if expired.
176    pub fn open_for(&self, expected_purpose: &str, token: &str) -> Result<String, EncryptError> {
177        let payload = self.decrypt_token(token)?;
178        let actual = payload.p.as_deref().unwrap_or("(none)");
179        if actual != expected_purpose {
180            return Err(EncryptError::WrongPurpose {
181                expected: expected_purpose.to_string(),
182                actual: actual.to_string(),
183            });
184        }
185        Self::check_expiry(&payload)?;
186        Ok(payload.v)
187    }
188
189    // ── expiring tokens ───────────────────────────────────────────────────────
190
191    /// Encrypt `value` with an expiry of `ttl` from now.
192    ///
193    /// Opening the token after `ttl` has elapsed returns `Err(Expired)`.
194    pub fn seal_expiring(&self, value: &str, ttl: Duration) -> String {
195        let expires_at = chrono::Utc::now().timestamp() + ttl.as_secs() as i64;
196        self.encrypt_payload(&Payload {
197            v: value.to_string(),
198            p: None,
199            e: Some(expires_at),
200        })
201    }
202
203    /// Encrypt `value` bound to `purpose` with an expiry of `ttl` from now.
204    pub fn seal_for_expiring(&self, purpose: &str, value: &str, ttl: Duration) -> String {
205        let expires_at = chrono::Utc::now().timestamp() + ttl.as_secs() as i64;
206        self.encrypt_payload(&Payload {
207            v: value.to_string(),
208            p: Some(purpose.to_string()),
209            e: Some(expires_at),
210        })
211    }
212}