diff --git a/Cargo.toml b/Cargo.toml index 34e74ff..36d5018 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ url = "1.6" lettre_email = "0.8" handlebars = "1.1.0" num_cpus = "1.0" +ring = "0.13" +base64 = "0.10" [dependencies.lettre] version = "0.8" diff --git a/src/main.rs b/src/main.rs index 5fec070..92332c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,9 +27,13 @@ extern crate tempfile; #[cfg(test)] extern crate regex; +extern crate ring; + extern crate hagrid_database as database; mod mail; mod web; +mod tokens; +mod sealed_state; fn main() { if let Err(e) = web::serve() { diff --git a/src/sealed_state.rs b/src/sealed_state.rs new file mode 100644 index 0000000..6e746d1 --- /dev/null +++ b/src/sealed_state.rs @@ -0,0 +1,68 @@ +use ring::aead::{seal_in_place, open_in_place, Algorithm, AES_256_GCM}; +use ring::aead::{OpeningKey, SealingKey}; +use ring::rand::{SecureRandom, SystemRandom}; +use ring::digest; + +// Keep these in sync, and keep the key len synced with the `private` docs as +// well as the `KEYS_INFO` const in secure::Key. +static ALGO: &'static Algorithm = &AES_256_GCM; +const NONCE_LEN: usize = 12; + +pub struct SealedState { + sealing_key: SealingKey, + opening_key: OpeningKey, +} + +impl SealedState { + pub fn new(secret: &str) -> Self { + // TODO use KDF + let salted_secret = "hagrid".to_owned() + secret; + let key = digest::digest(&digest::SHA256, salted_secret.as_bytes()); + + let sealing_key = SealingKey::new(ALGO, key.as_ref()).expect("sealing key creation"); + let opening_key = OpeningKey::new(ALGO, key.as_ref()).expect("sealing key creation"); + + SealedState { sealing_key, opening_key } + } + + pub fn unseal(&self, mut data: Vec) -> Result { + let (nonce, sealed) = data.split_at_mut(NONCE_LEN); + let unsealed = open_in_place(&self.opening_key, nonce, &[], 0, sealed) + .map_err(|_| "invalid key/nonce/value: bad seal")?; + + ::std::str::from_utf8(unsealed) + .map(|s| s.to_string()) + .map_err(|_| "bad unsealed utf8") + } + + pub fn seal(&self, input: &str) -> Vec { + let mut data; + let output_len = { + let overhead = ALGO.tag_len(); + data = vec![0; NONCE_LEN + input.len() + overhead]; + + let (nonce, in_out) = data.split_at_mut(NONCE_LEN); + SystemRandom::new().fill(nonce).expect("couldn't random fill nonce"); + in_out[..input.len()].copy_from_slice(input.as_bytes()); + + seal_in_place(&self.sealing_key, nonce, &[], in_out, overhead).expect("in-place seal") + }; + + data[..(NONCE_LEN + output_len)].to_vec() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encrypt_decrypt() { + let sv = SealedState::new("swag"); + + let sealed = sv.seal("test"); + let unsealed = sv.unseal(sealed).unwrap(); + + assert_eq!("test", unsealed); + } +} diff --git a/src/tokens.rs b/src/tokens.rs new file mode 100644 index 0000000..6174de5 --- /dev/null +++ b/src/tokens.rs @@ -0,0 +1,106 @@ +use sealed_state::SealedState; + +use std::time::SystemTime; + +use database::types::{Fingerprint}; +use serde_json; + +const REVISION: u8 = 1; + +pub struct Service { + sealed_state: SealedState, + validity: u64, +} + +#[derive(Serialize,Deserialize)] +struct Token { + #[serde(rename = "f")] + fpr: Fingerprint, + #[serde(rename = "c")] + creation: u64, + #[serde(rename = "r")] + revision: u8, +} + +impl Service { + pub fn init(secret: &str, validity: u64) -> Self { + let sealed_state = SealedState::new(secret); + Service { sealed_state, validity } + } + + pub fn create(&self, fpr: &Fingerprint) -> String { + let creation = current_time(); + let token = Token { fpr: fpr.clone(), creation, revision: REVISION }; + let token_serialized = serde_json::to_string(&token).unwrap(); + + let token_sealed = self.sealed_state.seal(&token_serialized); + + base64::encode_config(&token_sealed, base64::URL_SAFE_NO_PAD) + } + + pub fn check(&self, token_encoded: &str) -> Result { + let token_sealed = base64::decode_config(&token_encoded, base64::URL_SAFE_NO_PAD).map_err(|_| "invalid b64")?; + let token_str = self.sealed_state.unseal(token_sealed)?; + let token: Token = serde_json::from_str(&token_str) + .map_err(|_| "failed to deserialize")?; + + let elapsed = current_time() - token.creation; + if elapsed > self.validity { + Err("Token has expired!")?; + } + + Ok(token.fpr) + } + +} + +#[cfg(not(test))] +fn current_time() -> u64 { + SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs() +} + +#[cfg(test)] +fn current_time() -> u64 { + 12345678 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_check() { + let fpr = "D4AB192964F76A7F8F8A9B357BD18320DEADFA11".parse().unwrap(); + let mt = Service::init("secret", 60); + let token = mt.create(&fpr); + // println!("{}", &token); + // assert!(false); + + let check_result = mt.check(&token); + + assert_eq!(Ok(fpr), check_result); + } + + #[test] + fn test_ok() { + // {"f":"D4AB192964F76A7F8F8A9B357BD18320DEADFA11","c":12345658,"r":1} + let fpr = "D4AB192964F76A7F8F8A9B357BD18320DEADFA11".parse().unwrap(); + let token = "Gpi5wq4ALZSAQ7KaKmCzpgbWP2a7BImNC6H49ztqAD1Tl7qwJdbTIlyFWMEhkMcU-FIbvPkWUkBAP2EB6pP7-pWsIPmUT6sD_NNChwYaiDEMqMIFpcnb0xEPYKBpqZc"; + let mt = Service::init("secret", 60); + + let check_result = mt.check(token); + + assert_eq!(Ok(fpr), check_result); + } + + #[test] + fn test_expired() { + // {"f":"D4AB192964F76A7F8F8A9B357BD18320DEADFA11","c":12345078,"r":1} + let token = "KfbQMVE-U3thjmwrfAo1sdel9ixwd05fALaPfJ-6p_6AhN2_U0DaLUwAEFwLah-R6zTsQ_LNjMf8cu1z-pJnyB1DoSRYdy380HFT8sx6BnEFFXFyaU02bNM0wlv3Uzk"; + let mt = Service::init("secret", 60); + + let check_result = mt.check(token); + + assert_eq!(Err("Token has expired!".to_owned()), check_result); + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 25cd05b..2ea4a88 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; pub mod upload; use mail; +use tokens; use database::{Database, Polymorphic, Query}; use database::types::{Email, Fingerprint, KeyID}; @@ -418,6 +419,8 @@ pub fn serve() -> Result<()> { } fn rocket_factory(rocket: rocket::Rocket) -> Result { + use std::convert::TryFrom; + let routes = routes![ // infra root, @@ -478,10 +481,16 @@ fn rocket_factory(rocket: rocket::Rocket) -> Result { mail::Service::sendmail(from, base_uri, handlebars)? }; + let secret = rocket.config().get_str("token_secret")?.to_string(); + let validity = rocket.config().get_int("token_validity")?; + let validity = u64::try_from(validity)?; + let token_service = tokens::Service::init(&secret, validity); + Ok(rocket .attach(Template::fairing()) .manage(state) .manage(mail_service) + .manage(token_service) .mount("/", routes) .manage(db)) } @@ -533,6 +542,8 @@ pub mod tests { ) .extra("base-URI", BASE_URI) .extra("from", "from") + .extra("token_secret", "hagrid") + .extra("token_validity", 3600) .extra("filemail_into", filemail.into_os_string().into_string() .expect("path is valid UTF8")) .extra("x-accel-redirect", false)