summaryrefslogtreecommitdiff
path: root/src/crypto.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/crypto.rs')
-rw-r--r--src/crypto.rs408
1 files changed, 408 insertions, 0 deletions
diff --git a/src/crypto.rs b/src/crypto.rs
new file mode 100644
index 0000000..cf1044e
--- /dev/null
+++ b/src/crypto.rs
@@ -0,0 +1,408 @@
+#![deny(clippy::pedantic)]
+#![deny(clippy::restriction)]
+#![allow(clippy::blanket_clippy_restriction_lints)]
+#![allow(clippy::implicit_return)]
+#![allow(clippy::missing_docs_in_private_items)]
+#![allow(clippy::shadow_reuse)]
+
+use std::fmt::Debug;
+
+use hmac::{Hmac, Mac};
+use password_hash::{Output, Salt};
+use rand::RngCore;
+use scrypt::scrypt;
+use serde::{Deserialize, Serialize};
+use sha2::Sha256;
+
+const NAMESPACE: &[u8] = b"identity.mozilla.com/picl/v1/";
+
+#[derive(Clone, PartialEq, Eq, Zeroize, Serialize, Deserialize)]
+#[serde(try_from = "String", into = "String")]
+pub struct SecretBytes<const N: usize>(pub [u8; N]);
+
+impl<const N: usize> Drop for SecretBytes<N> {
+ fn drop(&mut self) {
+ self.zeroize();
+ }
+}
+
+#[derive(Clone, PartialEq, Eq)]
+pub struct TokenID(pub [u8; 32]);
+
+impl<const N: usize> Debug for SecretBytes<N> {
+ fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
+ fmt.write_fmt(format_args!("SecretBytes {{ raw: {} }}", hex::encode(&self.0)))
+ }
+}
+
+impl<const N: usize> SecretBytes<N> {
+ fn xor(&self, other: &Self) -> Self {
+ let mut result = self.clone();
+ for (a, b) in result.0.iter_mut().zip(other.0.iter()) {
+ *a ^= b;
+ }
+ result
+ }
+}
+
+impl<const N: usize> From<SecretBytes<N>> for String {
+ fn from(sb: SecretBytes<N>) -> Self {
+ hex::encode(&sb.0)
+ }
+}
+
+impl<const N: usize> TryFrom<String> for SecretBytes<N> {
+ type Error = hex::FromHexError;
+
+ fn try_from(value: String) -> Result<Self, Self::Error> {
+ let mut result = Self([0; N]);
+ hex::decode_to_slice(value, &mut result.0)?;
+ Ok(result)
+ }
+}
+
+impl From<SecretBytes<32>> for Output {
+ fn from(s: SecretBytes<32>) -> Output {
+ #[allow(clippy::unwrap_used)]
+ Output::new(&s.0).unwrap()
+ }
+}
+
+mod from_hkdf {
+ use hkdf::Hkdf;
+ use sha2::Sha256;
+
+ // sealing lets us guarantee that SIZE is always correct,
+ // which means that from_hkdf always receives correctly sized slices
+ // and copies never fail
+ mod private {
+ pub trait Seal {}
+ impl<const N: usize> Seal for super::super::SecretBytes<N> {}
+ impl Seal for super::super::TokenID {}
+ impl<L: Seal, R: Seal> Seal for (L, R) {}
+ }
+
+ pub trait FromHkdf: private::Seal {
+ const SIZE: usize;
+ fn from_hkdf(bytes: &[u8]) -> Self;
+ }
+
+ impl<const N: usize> FromHkdf for super::SecretBytes<N> {
+ const SIZE: usize = N;
+ fn from_hkdf(bytes: &[u8]) -> Self {
+ #[allow(clippy::unwrap_used)]
+ Self(bytes.try_into().unwrap())
+ }
+ }
+
+ impl FromHkdf for super::TokenID {
+ const SIZE: usize = 32;
+ fn from_hkdf(bytes: &[u8]) -> Self {
+ #[allow(clippy::expect_used)]
+ Self(bytes.try_into().expect("hkdf failed"))
+ }
+ }
+
+ impl<L: FromHkdf, R: FromHkdf> FromHkdf for (L, R) {
+ const SIZE: usize = L::SIZE + R::SIZE;
+ #[allow(clippy::indexing_slicing)]
+ fn from_hkdf(bytes: &[u8]) -> Self {
+ (L::from_hkdf(&bytes[0..L::SIZE]), R::from_hkdf(&bytes[L::SIZE..]))
+ }
+ }
+
+ pub fn from_hkdf<O: FromHkdf>(key: &[u8], info: &[&[u8]]) -> O {
+ let hk = Hkdf::<Sha256>::new(None, key);
+ let mut buf = vec![0; O::SIZE];
+ #[allow(clippy::expect_used)]
+ // worth keeping an eye out for very large results (>255*32 bytes)
+ hk.expand_multi_info(info, buf.as_mut_slice()).expect("hkdf failed");
+ O::from_hkdf(&buf)
+ }
+}
+
+use from_hkdf::from_hkdf;
+use zeroize::Zeroize;
+
+impl<const N: usize> SecretBytes<N> {
+ pub fn generate() -> Self {
+ let mut result = Self([0; N]);
+ rand::rngs::OsRng.fill_bytes(&mut result.0);
+ result
+ }
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(transparent)]
+pub struct AuthPW {
+ pub pw: SecretBytes<32>,
+}
+
+pub struct StretchedPW {
+ pub pw: Output,
+}
+
+impl AuthPW {
+ pub fn stretch(&self, salt: Salt) -> anyhow::Result<StretchedPW> {
+ let mut result = [0; 32];
+ let mut buf = [0; Salt::MAX_LENGTH];
+ let params = scrypt::Params::new(16, 8, 1)?;
+ let salt = salt.b64_decode(&mut buf)?;
+ scrypt(&self.pw.0, salt, &params, &mut result)?;
+ Ok(StretchedPW { pw: Output::new(&result)? })
+ }
+}
+
+impl StretchedPW {
+ pub fn verify_hash(&self) -> Output {
+ let raw: SecretBytes<32> = from_hkdf(self.pw.as_bytes(), &[NAMESPACE, b"verifyHash"]);
+ raw.into()
+ }
+
+ fn wrap_wrap_key(&self) -> SecretBytes<32> {
+ from_hkdf(self.pw.as_bytes(), &[NAMESPACE, b"wrapwrapKey"])
+ }
+
+ pub fn decrypt_wwkb(&self, wwkb: &SecretBytes<32>) -> SecretBytes<32> {
+ wwkb.xor(&self.wrap_wrap_key())
+ }
+
+ pub fn rewrap_wkb(&self, wkb: &SecretBytes<32>) -> SecretBytes<32> {
+ wkb.xor(&self.wrap_wrap_key())
+ }
+}
+
+pub struct SessionCredentials {
+ pub token_id: TokenID,
+ pub req_hmac_key: SecretBytes<32>,
+}
+
+impl SessionCredentials {
+ pub fn derive(seed: &SecretBytes<32>) -> Self {
+ let (token_id, req_hmac_key) = from_hkdf(&seed.0, &[NAMESPACE, b"sessionToken"]);
+ Self { token_id, req_hmac_key }
+ }
+}
+
+pub struct KeyFetchReq {
+ pub token_id: TokenID,
+ pub req_hmac_key: SecretBytes<32>,
+ pub key_request_key: SecretBytes<32>,
+}
+
+impl KeyFetchReq {
+ pub fn from_token(key_fetch_token: &SecretBytes<32>) -> Self {
+ let (token_id, (req_hmac_key, key_request_key)) =
+ from_hkdf(&key_fetch_token.0, &[NAMESPACE, b"keyFetchToken"]);
+ Self { token_id, req_hmac_key, key_request_key }
+ }
+
+ pub fn derive_resp(&self) -> KeyFetchResp {
+ let (resp_hmac_key, resp_xor_key) =
+ from_hkdf(&self.key_request_key.0, &[NAMESPACE, b"account/keys"]);
+ KeyFetchResp { resp_hmac_key, resp_xor_key }
+ }
+}
+
+pub struct KeyFetchResp {
+ pub resp_hmac_key: SecretBytes<32>,
+ pub resp_xor_key: SecretBytes<64>,
+}
+
+impl KeyFetchResp {
+ pub fn wrap_keys(&self, keys: &KeyBundle) -> WrappedKeyBundle {
+ let ciphertext = self.resp_xor_key.xor(&keys.to_bytes());
+ #[allow(clippy::unwrap_used)]
+ let mut hmac = Hmac::<Sha256>::new_from_slice(&self.resp_hmac_key.0).unwrap();
+ hmac.update(&ciphertext.0);
+ let hmac = *hmac.finalize().into_bytes().as_ref();
+ WrappedKeyBundle { ciphertext, hmac }
+ }
+}
+
+pub struct KeyBundle {
+ pub ka: SecretBytes<32>,
+ pub wrap_kb: SecretBytes<32>,
+}
+
+impl KeyBundle {
+ pub fn to_bytes(&self) -> SecretBytes<64> {
+ let mut result = SecretBytes([0; 64]);
+ result.0[0..32].copy_from_slice(&self.ka.0);
+ result.0[32..].copy_from_slice(&self.wrap_kb.0);
+ result
+ }
+}
+
+#[derive(Debug)]
+pub struct WrappedKeyBundle {
+ pub ciphertext: SecretBytes<64>,
+ pub hmac: [u8; 32],
+}
+
+impl WrappedKeyBundle {
+ pub fn to_bytes(&self) -> [u8; 96] {
+ let mut result = [0; 96];
+ result[0..64].copy_from_slice(&self.ciphertext.0);
+ result[64..].copy_from_slice(&self.hmac);
+ result
+ }
+}
+
+pub struct PasswordChangeReq {
+ pub token_id: TokenID,
+ pub req_hmac_key: SecretBytes<32>,
+}
+
+impl PasswordChangeReq {
+ pub fn from_change_token(token: &SecretBytes<32>) -> Self {
+ let (token_id, req_hmac_key) = from_hkdf(&token.0, &[NAMESPACE, b"passwordChangeToken"]);
+ Self { token_id, req_hmac_key }
+ }
+
+ pub fn from_forgot_token(token: &SecretBytes<32>) -> Self {
+ let (token_id, req_hmac_key) = from_hkdf(&token.0, &[NAMESPACE, b"passwordForgotToken"]);
+ Self { token_id, req_hmac_key }
+ }
+}
+
+pub struct AccountResetReq {
+ pub token_id: TokenID,
+ pub req_hmac_key: SecretBytes<32>,
+}
+
+impl AccountResetReq {
+ pub fn from_token(token: &SecretBytes<32>) -> Self {
+ let (token_id, req_hmac_key) = from_hkdf(&token.0, &[NAMESPACE, b"accountResetToken"]);
+ Self { token_id, req_hmac_key }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use hex_literal::hex;
+ use password_hash::{Output, SaltString};
+
+ use crate::crypto::{KeyBundle, KeyFetchReq, SessionCredentials};
+
+ use super::{AuthPW, SecretBytes};
+
+ macro_rules! shex {
+ ( $s: literal ) => {
+ SecretBytes(hex!($s))
+ };
+ }
+
+ #[test]
+ fn test_derive_session() {
+ let creds = SessionCredentials::derive(&SecretBytes(hex!(
+ "a0a1a2a3a4a5a6a7 a8a9aaabacadaeaf b0b1b2b3b4b5b6b7 b8b9babbbcbdbebf"
+ )));
+ assert_eq!(
+ creds.token_id.0,
+ hex!("c0a29dcf46174973da1378696e4c82ae10f723cf4f4d9f75e39f4ae3851595ab")
+ );
+ assert_eq!(
+ creds.req_hmac_key.0,
+ hex!("9d8f22998ee7f579 8b887042466b72d5 3e56ab0c094388bf 65831f702d2febc0")
+ );
+ }
+
+ #[test]
+ fn test_key_fetch() {
+ let key_fetch = KeyFetchReq::from_token(&shex!(
+ "8081828384858687 88898a8b8c8d8e8f 9091929394959697 98999a9b9c9d9e9f"
+ ));
+ assert_eq!(
+ key_fetch.token_id.0,
+ hex!("3d0a7c02a15a62a2882f76e39b6494b500c022a8816e048625a495718998ba60")
+ );
+ assert_eq!(
+ key_fetch.req_hmac_key.0,
+ hex!("87b8937f61d38d0e 29cd2d5600b3f4da 0aa48ac41de36a0e fe84bb4a9872ceb7")
+ );
+ assert_eq!(
+ key_fetch.key_request_key.0,
+ hex!("14f338a9e8c6324d 9e102d4e6ee83b20 9796d5c74bb734a4 10e729e014a4a546")
+ );
+
+ let resp = key_fetch.derive_resp();
+ assert_eq!(
+ resp.resp_hmac_key.0,
+ hex!("f824d2953aab9faf 51a1cb65ba9e7f9e 5bf91c8d8fd1ac1c 8c2d31853a8a1210")
+ );
+ assert_eq!(
+ resp.resp_xor_key.0,
+ hex!(
+ "ce7d7aa77859b235 9932970bbe2101f2 e80d01faf9191bd5 ee52181d2f0b7809
+ 8281ba8cff392543 3a89f7c3095e0c89 900a469d60790c83 3281c4df1a11c763"
+ )
+ );
+
+ let bundle = KeyBundle {
+ ka: shex!("2021222324252627 28292a2b2c2d2e2f 3031323334353637 38393a3b3c3d3e3f"),
+ wrap_kb: shex!("7effe354abecbcb2 34a8dfc2d7644b4a d339b525589738f2 d27341bb8622ecd8"),
+ };
+ assert_eq!(
+ bundle.to_bytes().0,
+ hex!(
+ "2021222324252627 28292a2b2c2d2e2f 3031323334353637 38393a3b3c3d3e3f
+ 7effe354abecbcb2 34a8dfc2d7644b4a d339b525589738f2 d27341bb8622ecd8"
+ )
+ );
+
+ let wrapped = resp.wrap_keys(&bundle);
+ assert_eq!(
+ wrapped.ciphertext.0,
+ hex!(
+ "ee5c58845c7c9412 b11bbd20920c2fdd d83c33c9cd2c2de2 d66b222613364636
+ fc7e59d854d599f1 0e212801de3a47c3 4333f3b838ee3471 e0f285649c332bbb"
+ )
+ );
+ assert_eq!(
+ wrapped.hmac,
+ hex!("4c17f42a0b319bbb a327d2b326ad23e9 37219b4de32e3ec7 b3e3f740522ad6ef")
+ );
+ assert_eq!(
+ wrapped.to_bytes(),
+ hex!(
+ "ee5c58845c7c9412 b11bbd20920c2fdd d83c33c9cd2c2de2 d66b222613364636
+ fc7e59d854d599f1 0e212801de3a47c3 4333f3b838ee3471 e0f285649c332bbb
+ 4c17f42a0b319bbb a327d2b326ad23e9 37219b4de32e3ec7 b3e3f740522ad6ef"
+ )
+ );
+ }
+
+ #[test]
+ fn test_stretch() -> anyhow::Result<()> {
+ let auth_pw = AuthPW {
+ pw: SecretBytes(hex!(
+ "247b675ffb4c4631 0bc87e26d712153a be5e1c90ef00a478 4594f97ef54f2375"
+ )),
+ };
+
+ let stretched = auth_pw.stretch(
+ SaltString::b64_encode(&hex!(
+ "00f0000000000000 0000000000000000 0000000000000000 0000000000000000"
+ ))?
+ .as_salt(),
+ )?;
+ assert_eq!(
+ stretched.pw,
+ Output::new(&hex!(
+ "441509e25c92ee10 3d5a1a874e6f155d f25a44d06e61c894 616c9e85181dba97"
+ ))?
+ );
+
+ assert_eq!(
+ stretched.verify_hash().as_bytes(),
+ hex!("a4765bf103dc057f 4cf4bc2c131ddb67 16e8a4333cc55e1d 3c449f31f0eec4f1")
+ );
+
+ assert_eq!(
+ stretched.wrap_wrap_key().0,
+ hex!("3ebea117efa9faf5 7ce195899b290505 8368e7760cc26ea5 8a2a1be0da7fb287")
+ );
+ Ok(())
+ }
+}