commit 34f7361a1bc0c246ef1c9d3b05ce5a65200627af Author: lumi Date: Mon Feb 27 16:08:09 2017 +0100 initial commit diff --git a/sasl/.gitignore b/sasl/.gitignore new file mode 100644 index 00000000..a9d37c56 --- /dev/null +++ b/sasl/.gitignore @@ -0,0 +1,2 @@ +target +Cargo.lock diff --git a/sasl/.gitlab-ci.yml b/sasl/.gitlab-ci.yml new file mode 100644 index 00000000..d29f6f16 --- /dev/null +++ b/sasl/.gitlab-ci.yml @@ -0,0 +1,10 @@ +image: "scorpil/rust:stable" + +before_script: +- apt-get update -yqq +- apt-get install -yqq --no-install-recommends build-essential libssl-dev pkg-config + +test:cargo: + script: + - rustc --version && cargo --version + - cargo test --verbose --jobs 1 --release diff --git a/sasl/Cargo.toml b/sasl/Cargo.toml new file mode 100644 index 00000000..aa97ef16 --- /dev/null +++ b/sasl/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "sasl" +version = "0.1.0" +authors = ["lumi "] + +[dependencies] +openssl = "0.9.7" +base64 = "0.4.0" diff --git a/sasl/src/error.rs b/sasl/src/error.rs new file mode 100644 index 00000000..4192300a --- /dev/null +++ b/sasl/src/error.rs @@ -0,0 +1,13 @@ +use openssl::error::ErrorStack; + +#[derive(Debug)] +pub enum Error { + OpenSslErrorStack(ErrorStack), + SaslError(String), +} + +impl From for Error { + fn from(err: ErrorStack) -> Error { + Error::OpenSslErrorStack(err) + } +} diff --git a/sasl/src/lib.rs b/sasl/src/lib.rs new file mode 100644 index 00000000..07082a67 --- /dev/null +++ b/sasl/src/lib.rs @@ -0,0 +1,46 @@ +//! Provides the `SaslMechanism` trait and some implementations. + +extern crate openssl; +extern crate base64; + +pub mod error; + +/// A struct containing SASL credentials. +pub struct SaslCredentials { + pub username: String, + pub secret: SaslSecret, + pub channel_binding: Option>, +} + +/// Represents a SASL secret, like a password. +pub enum SaslSecret { + /// No extra data needed. + None, + /// Password required. + Password(String), +} + +pub trait SaslMechanism { + /// The name of the mechanism. + fn name(&self) -> &str; + + /// Creates this mechanism from `SaslCredentials`. + fn from_credentials(credentials: SaslCredentials) -> Result where Self: Sized; + + /// Provides initial payload of the SASL mechanism. + fn initial(&mut self) -> Result, String> { + Ok(Vec::new()) + } + + /// Creates a response to the SASL challenge. + fn response(&mut self, _challenge: &[u8]) -> Result, String> { + Ok(Vec::new()) + } + + /// Verifies the server success response, if there is one. + fn success(&mut self, _data: &[u8]) -> Result<(), String> { + Ok(()) + } +} + +pub mod mechanisms; diff --git a/sasl/src/mechanisms/anonymous.rs b/sasl/src/mechanisms/anonymous.rs new file mode 100644 index 00000000..9d595e72 --- /dev/null +++ b/sasl/src/mechanisms/anonymous.rs @@ -0,0 +1,26 @@ +//! Provides the SASL "ANONYMOUS" mechanism. + +use SaslMechanism; +use SaslCredentials; +use SaslSecret; + +pub struct Anonymous; + +impl Anonymous { + pub fn new() -> Anonymous { + Anonymous + } +} + +impl SaslMechanism for Anonymous { + fn name(&self) -> &str { "ANONYMOUS" } + + fn from_credentials(credentials: SaslCredentials) -> Result { + if let SaslSecret::None = credentials.secret { + Ok(Anonymous) + } + else { + Err("the anonymous sasl mechanism requires no credentials".to_owned()) + } + } +} diff --git a/sasl/src/mechanisms/mod.rs b/sasl/src/mechanisms/mod.rs new file mode 100644 index 00000000..59463ac0 --- /dev/null +++ b/sasl/src/mechanisms/mod.rs @@ -0,0 +1,9 @@ +///! Provides a few SASL mechanisms. + +mod anonymous; +mod plain; +mod scram; + +pub use self::anonymous::Anonymous; +pub use self::plain::Plain; +pub use self::scram::{Scram, Sha1, Sha256, ScramProvider}; diff --git a/sasl/src/mechanisms/plain.rs b/sasl/src/mechanisms/plain.rs new file mode 100644 index 00000000..58600ceb --- /dev/null +++ b/sasl/src/mechanisms/plain.rs @@ -0,0 +1,41 @@ +//! Provides the SASL "PLAIN" mechanism. + +use SaslMechanism; +use SaslCredentials; +use SaslSecret; + +pub struct Plain { + username: String, + password: String, +} + +impl Plain { + pub fn new, P: Into>(username: N, password: P) -> Plain { + Plain { + username: username.into(), + password: password.into(), + } + } +} + +impl SaslMechanism for Plain { + fn name(&self) -> &str { "PLAIN" } + + fn from_credentials(credentials: SaslCredentials) -> Result { + if let SaslSecret::Password(password) = credentials.secret { + Ok(Plain::new(credentials.username, password)) + } + else { + Err("PLAIN requires a password".to_owned()) + } + } + + fn initial(&mut self) -> Result, String> { + let mut auth = Vec::new(); + auth.push(0); + auth.extend(self.username.bytes()); + auth.push(0); + auth.extend(self.password.bytes()); + Ok(auth) + } +} diff --git a/sasl/src/mechanisms/scram.rs b/sasl/src/mechanisms/scram.rs new file mode 100644 index 00000000..efa2f5d4 --- /dev/null +++ b/sasl/src/mechanisms/scram.rs @@ -0,0 +1,328 @@ +//! Provides the SASL "SCRAM-*" mechanisms and a way to implement more. + +use base64; + +use SaslMechanism; +use SaslCredentials; +use SaslSecret; + +use error::Error; + +use openssl::pkcs5::pbkdf2_hmac; +use openssl::hash::hash; +use openssl::hash::MessageDigest; +use openssl::sign::Signer; +use openssl::pkey::PKey; +use openssl::rand::rand_bytes; +use openssl::error::ErrorStack; + +use std::marker::PhantomData; + +use std::collections::HashMap; + +use std::string::FromUtf8Error; + +#[cfg(test)] +#[test] +fn xor_works() { + assert_eq!( xor( &[135, 94, 53, 134, 73, 233, 140, 221, 150, 12, 96, 111, 54, 66, 11, 76] + , &[163, 9, 122, 180, 107, 44, 22, 252, 248, 134, 112, 82, 84, 122, 56, 209] ) + , &[36, 87, 79, 50, 34, 197, 154, 33, 110, 138, 16, 61, 98, 56, 51, 157] ); +} + +fn xor(a: &[u8], b: &[u8]) -> Vec { + assert_eq!(a.len(), b.len()); + let mut ret = Vec::with_capacity(a.len()); + for (a, b) in a.into_iter().zip(b) { + ret.push(a ^ b); + } + ret +} + +fn parse_frame(frame: &[u8]) -> Result, FromUtf8Error> { + let inner = String::from_utf8(frame.to_owned())?; + let mut ret = HashMap::new(); + for s in inner.split(',') { + let mut tmp = s.splitn(2, '='); + let key = tmp.next(); + let val = tmp.next(); + match (key, val) { + (Some(k), Some(v)) => { + ret.insert(k.to_owned(), v.to_owned()); + }, + _ =>(), + } + } + Ok(ret) +} + +fn generate_nonce() -> Result { + let mut data = vec![0; 32]; + rand_bytes(&mut data)?; + Ok(base64::encode(&data)) +} + +pub trait ScramProvider { + fn name() -> &'static str; + fn hash(data: &[u8]) -> Vec; + fn hmac(data: &[u8], key: &[u8]) -> Vec; + fn derive(data: &[u8], salt: &[u8], iterations: usize) -> Vec; +} + +pub struct Sha1; + +impl ScramProvider for Sha1 { // TODO: look at all these unwraps + fn name() -> &'static str { "SHA-1" } + + fn hash(data: &[u8]) -> Vec { + hash(MessageDigest::sha1(), data).unwrap() + } + + fn hmac(data: &[u8], key: &[u8]) -> Vec { + let pkey = PKey::hmac(key).unwrap(); + let mut signer = Signer::new(MessageDigest::sha1(), &pkey).unwrap(); + signer.update(data).unwrap(); + signer.finish().unwrap() + } + + fn derive(data: &[u8], salt: &[u8], iterations: usize) -> Vec { + let mut result = vec![0; 20]; + pbkdf2_hmac(data, salt, iterations, MessageDigest::sha1(), &mut result).unwrap(); + result + } +} + +pub struct Sha256; + +impl ScramProvider for Sha256 { // TODO: look at all these unwraps + fn name() -> &'static str { "SHA-256" } + + fn hash(data: &[u8]) -> Vec { + hash(MessageDigest::sha256(), data).unwrap() + } + + fn hmac(data: &[u8], key: &[u8]) -> Vec { + let pkey = PKey::hmac(key).unwrap(); + let mut signer = Signer::new(MessageDigest::sha256(), &pkey).unwrap(); + signer.update(data).unwrap(); + signer.finish().unwrap() + } + + fn derive(data: &[u8], salt: &[u8], iterations: usize) -> Vec { + let mut result = vec![0; 32]; + pbkdf2_hmac(data, salt, iterations, MessageDigest::sha256(), &mut result).unwrap(); + result + } +} + +enum ScramState { + Init, + SentInitialMessage { initial_message: Vec, gs2_header: Vec}, + GotServerData { server_signature: Vec }, +} + +pub struct Scram { + name: String, + username: String, + password: String, + client_nonce: String, + state: ScramState, + channel_binding: Option>, + _marker: PhantomData, +} + +impl Scram { + pub fn new, P: Into>(username: N, password: P) -> Result, Error> { + Ok(Scram { + name: format!("SCRAM-{}", S::name()), + username: username.into(), + password: password.into(), + client_nonce: generate_nonce()?, + state: ScramState::Init, + channel_binding: None, + _marker: PhantomData, + }) + } + + pub fn new_with_nonce, P: Into>(username: N, password: P, nonce: String) -> Scram { + Scram { + name: format!("SCRAM-{}", S::name()), + username: username.into(), + password: password.into(), + client_nonce: nonce, + state: ScramState::Init, + channel_binding: None, + _marker: PhantomData, + } + } + + pub fn new_with_channel_binding, P: Into>(username: N, password: P, channel_binding: Vec) -> Result, Error> { + Ok(Scram { + name: format!("SCRAM-{}-PLUS", S::name()), + username: username.into(), + password: password.into(), + client_nonce: generate_nonce()?, + state: ScramState::Init, + channel_binding: Some(channel_binding), + _marker: PhantomData, + }) + } +} + +impl SaslMechanism for Scram { + fn name(&self) -> &str { // TODO: this is quite the workaround… + &self.name + } + + fn from_credentials(credentials: SaslCredentials) -> Result, String> { + if let SaslSecret::Password(password) = credentials.secret { + if let Some(binding) = credentials.channel_binding { + Scram::new_with_channel_binding(credentials.username, password, binding) + .map_err(|_| "can't generate nonce".to_owned()) + } + else { + Scram::new(credentials.username, password) + .map_err(|_| "can't generate nonce".to_owned()) + } + } + else { + Err("SCRAM requires a password".to_owned()) + } + } + + fn initial(&mut self) -> Result, String> { + let mut gs2_header = Vec::new(); + if let Some(_) = self.channel_binding { + gs2_header.extend(b"p=tls-unique,,"); + } + else { + gs2_header.extend(b"n,,"); + } + let mut bare = Vec::new(); + bare.extend(b"n="); + bare.extend(self.username.bytes()); + bare.extend(b",r="); + bare.extend(self.client_nonce.bytes()); + let mut data = Vec::new(); + data.extend(&gs2_header); + data.extend(bare.clone()); + self.state = ScramState::SentInitialMessage { initial_message: bare, gs2_header: gs2_header }; + Ok(data) + } + + fn response(&mut self, challenge: &[u8]) -> Result, String> { + let next_state; + let ret; + match self.state { + ScramState::SentInitialMessage { ref initial_message, ref gs2_header } => { + let frame = parse_frame(challenge).map_err(|_| "can't decode challenge".to_owned())?; + let server_nonce = frame.get("r"); + let salt = frame.get("s").and_then(|v| base64::decode(v).ok()); + let iterations = frame.get("i").and_then(|v| v.parse().ok()); + let server_nonce = server_nonce.ok_or_else(|| "no server nonce".to_owned())?; + let salt = salt.ok_or_else(|| "no server salt".to_owned())?; + let iterations = iterations.ok_or_else(|| "no server iterations".to_owned())?; + // TODO: SASLprep + let mut client_final_message_bare = Vec::new(); + client_final_message_bare.extend(b"c="); + let mut cb_data: Vec = Vec::new(); + cb_data.extend(gs2_header); + if let Some(ref cb) = self.channel_binding { + cb_data.extend(cb); + } + client_final_message_bare.extend(base64::encode(&cb_data).bytes()); + client_final_message_bare.extend(b",r="); + client_final_message_bare.extend(server_nonce.bytes()); + let salted_password = S::derive(self.password.as_bytes(), &salt, iterations); + let client_key = S::hmac(b"Client Key", &salted_password); + let server_key = S::hmac(b"Server Key", &salted_password); + let mut auth_message = Vec::new(); + auth_message.extend(initial_message); + auth_message.push(b','); + auth_message.extend(challenge); + auth_message.push(b','); + auth_message.extend(&client_final_message_bare); + let stored_key = S::hash(&client_key); + let client_signature = S::hmac(&auth_message, &stored_key); + let client_proof = xor(&client_key, &client_signature); + let server_signature = S::hmac(&auth_message, &server_key); + let mut client_final_message = Vec::new(); + client_final_message.extend(&client_final_message_bare); + client_final_message.extend(b",p="); + client_final_message.extend(base64::encode(&client_proof).bytes()); + next_state = ScramState::GotServerData { + server_signature: server_signature, + }; + ret = client_final_message; + }, + _ => { return Err("not in the right state to receive this response".to_owned()); } + } + self.state = next_state; + Ok(ret) + } + + fn success(&mut self, data: &[u8]) -> Result<(), String> { + let frame = parse_frame(data).map_err(|_| "can't decode success response".to_owned())?; + match self.state { + ScramState::GotServerData { ref server_signature } => { + if let Some(sig) = frame.get("v").and_then(|v| base64::decode(&v).ok()) { + if sig == *server_signature { + Ok(()) + } + else { + Err("invalid signature in success response".to_owned()) + } + } + else { + Err("no signature in success response".to_owned()) + } + }, + _ => Err("not in the right state to get a success response".to_owned()), + } + } +} + +#[cfg(test)] +mod tests { + use ::SaslMechanism; + + use super::*; + + #[test] + fn scram_sha1_works() { // Source: https://wiki.xmpp.org/web/SASLandSCRAM-SHA-1 + let username = "user"; + let password = "pencil"; + let client_nonce = "fyko+d2lbbFgONRv9qkxdawL"; + let client_init = b"n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL"; + let server_init = b"r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096"; + let client_final = b"c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,p=v0X8v3Bz2T0CJGbJQyF0X+HI4Ts="; + let server_final = b"v=rmF9pqV8S7suAoZWja4dJRkFsKQ="; + let mut mechanism = Scram::::new_with_nonce(username, password, client_nonce.to_owned()); + let init = mechanism.initial().unwrap(); + assert_eq!( String::from_utf8(init.clone()).unwrap() + , String::from_utf8(client_init[..].to_owned()).unwrap() ); // depends on ordering… + let resp = mechanism.response(&server_init[..]).unwrap(); + assert_eq!( String::from_utf8(resp.clone()).unwrap() + , String::from_utf8(client_final[..].to_owned()).unwrap() ); // again, depends on ordering… + mechanism.success(&server_final[..]).unwrap(); + } + + #[test] + fn scram_sha256_works() { // Source: RFC 7677 + let username = "user"; + let password = "pencil"; + let client_nonce = "rOprNGfwEbeRWgbNEkqO"; + let client_init = b"n,,n=user,r=rOprNGfwEbeRWgbNEkqO"; + let server_init = b"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096"; + let client_final = b"c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ="; + let server_final = b"v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4="; + let mut mechanism = Scram::::new_with_nonce(username, password, client_nonce.to_owned()); + let init = mechanism.initial().unwrap(); + assert_eq!( String::from_utf8(init.clone()).unwrap() + , String::from_utf8(client_init[..].to_owned()).unwrap() ); // depends on ordering… + let resp = mechanism.response(&server_init[..]).unwrap(); + assert_eq!( String::from_utf8(resp.clone()).unwrap() + , String::from_utf8(client_final[..].to_owned()).unwrap() ); // again, depends on ordering… + mechanism.success(&server_final[..]).unwrap(); + } +}