diff --git a/crates/relicario-core/Cargo.toml b/crates/relicario-core/Cargo.toml index e483ac4..37bbd26 100644 --- a/crates/relicario-core/Cargo.toml +++ b/crates/relicario-core/Cargo.toml @@ -12,6 +12,8 @@ argon2 = "0.5" chacha20poly1305 = "0.10" rand = "0.8" sha2 = "0.10" +sha1 = "0.10" +hmac = "0.12" ed25519-dalek = { version = "2", features = ["rand_core"] } image = { version = "0.25", default-features = false, features = ["jpeg"] } diff --git a/crates/relicario-core/src/item_types/mod.rs b/crates/relicario-core/src/item_types/mod.rs index 0dd9e61..74edc78 100644 --- a/crates/relicario-core/src/item_types/mod.rs +++ b/crates/relicario-core/src/item_types/mod.rs @@ -21,7 +21,7 @@ pub use identity::IdentityCore; pub use card::{CardCore, CardKind}; pub use key::KeyCore; pub use document::DocumentCore; -pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind}; +pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind, compute_totp_code}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/relicario-core/src/item_types/totp.rs b/crates/relicario-core/src/item_types/totp.rs index 1b6c52f..58ce1f8 100644 --- a/crates/relicario-core/src/item_types/totp.rs +++ b/crates/relicario-core/src/item_types/totp.rs @@ -1,8 +1,13 @@ //! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login. +use hmac::{Hmac, Mac}; +use sha1::Sha1 as HmacSha1; +use sha2::{Sha256 as HmacSha256, Sha512 as HmacSha512}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; +use crate::error::{RelicarioError, Result}; + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TotpCore { pub config: TotpConfig, @@ -55,6 +60,63 @@ impl Default for TotpKind { fn default() -> Self { TotpKind::Totp } } +/// Compute a TOTP/HOTP/Steam code for `config` at the given Unix timestamp. +/// +/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`. +/// For HOTP: uses the `counter` carried in the variant. +pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result { + let counter = match config.kind { + TotpKind::Totp => now_unix_seconds / config.period_seconds as u64, + TotpKind::Hotp { counter } => counter, + TotpKind::Steam => now_unix_seconds / config.period_seconds as u64, + }; + let counter_bytes = counter.to_be_bytes(); + let hmac_out: Vec = match config.algorithm { + TotpAlgorithm::Sha1 => { + let mut mac = Hmac::::new_from_slice(&config.secret) + .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?; + mac.update(&counter_bytes); + mac.finalize().into_bytes().to_vec() + } + TotpAlgorithm::Sha256 => { + let mut mac = Hmac::::new_from_slice(&config.secret) + .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?; + mac.update(&counter_bytes); + mac.finalize().into_bytes().to_vec() + } + TotpAlgorithm::Sha512 => { + let mut mac = Hmac::::new_from_slice(&config.secret) + .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?; + mac.update(&counter_bytes); + mac.finalize().into_bytes().to_vec() + } + }; + let offset = (hmac_out[hmac_out.len() - 1] & 0x0F) as usize; + let truncated = ((hmac_out[offset] as u32 & 0x7F) << 24) + | ((hmac_out[offset + 1] as u32) << 16) + | ((hmac_out[offset + 2] as u32) << 8) + | (hmac_out[offset + 3] as u32); + let modulus = 10u32.pow(config.digits as u32); + Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize)) +} + +#[cfg(test)] +mod compute_tests { + use super::*; + + #[test] + fn rfc6238_sha1_vector_59() { + let cfg = TotpConfig { + secret: Zeroizing::new(b"12345678901234567890".to_vec()), + algorithm: TotpAlgorithm::Sha1, + digits: 8, + period_seconds: 30, + kind: TotpKind::Totp, + }; + assert_eq!(compute_totp_code(&cfg, 59).unwrap(), "94287082"); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index f3b5a2e..03ce2ed 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -115,6 +115,123 @@ pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result, +} + +#[wasm_bindgen] +impl EncryptedAttachment { + #[wasm_bindgen(getter)] pub fn aid(&self) -> String { self.aid.clone() } + #[wasm_bindgen(getter)] pub fn bytes(&self) -> Vec { self.bytes.clone() } +} + +#[wasm_bindgen] +pub fn attachment_encrypt( + handle: &SessionHandle, + plaintext: &[u8], + max_bytes: u64, +) -> Result { + need_key(handle)?; + let enc = session::with(handle.0, |k| encrypt_attachment(plaintext, k, max_bytes)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(EncryptedAttachment { aid: enc.id.as_str().to_owned(), bytes: enc.bytes }) +} + +#[wasm_bindgen] +pub fn attachment_decrypt( + handle: &SessionHandle, + encrypted: &[u8], +) -> Result, JsError> { + need_key(handle)?; + let plain = session::with(handle.0, |k| decrypt_attachment(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(plain.to_vec()) +} + +#[wasm_bindgen] pub fn new_item_id() -> String { ItemId::new().as_str().to_owned() } +#[wasm_bindgen] pub fn new_field_id() -> String { FieldId::new().as_str().to_owned() } + +use relicario_core::{ + generate_passphrase as core_generate_passphrase, + generate_password as core_generate_password, + rate_passphrase as core_rate_passphrase, + GeneratorRequest, +}; + +#[wasm_bindgen] +pub fn generate_password(request_json: &str) -> Result { + let req: GeneratorRequest = serde_json::from_str(request_json) + .map_err(|e| JsError::new(&format!("generator request: {e}")))?; + let out = core_generate_password(&req).map_err(|e| JsError::new(&e.to_string()))?; + Ok(out.as_str().to_owned()) +} + +#[wasm_bindgen] +pub fn generate_passphrase(request_json: &str) -> Result { + let req: GeneratorRequest = serde_json::from_str(request_json) + .map_err(|e| JsError::new(&format!("generator request: {e}")))?; + let out = core_generate_passphrase(&req).map_err(|e| JsError::new(&e.to_string()))?; + Ok(out.as_str().to_owned()) +} + +#[wasm_bindgen] +pub fn rate_passphrase(p: &str) -> Result { + let est = core_rate_passphrase(p); + to_value(&serde_json::json!({ + "score": est.score, + "guesses_log10": est.guesses_log10, + })).map_err(|e| JsError::new(&e.to_string())) +} + +#[wasm_bindgen] +pub fn extract_image_secret(image_bytes: &[u8]) -> Result, JsError> { + let s = imgsecret::extract(image_bytes).map_err(|e| JsError::new(&e.to_string()))?; + Ok(s.to_vec()) +} + +#[wasm_bindgen] +pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result, JsError> { + let s: &[u8; 32] = secret.try_into() + .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?; + imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string())) +} + +use relicario_core::item_types::{TotpConfig, compute_totp_code}; + +#[wasm_bindgen] +pub struct TotpCode { + code: String, + expires_at: u64, +} + +#[wasm_bindgen] +impl TotpCode { + #[wasm_bindgen(getter)] pub fn code(&self) -> String { self.code.clone() } + #[wasm_bindgen(getter)] pub fn expires_at(&self) -> u64 { self.expires_at } +} + +#[wasm_bindgen] +pub fn totp_compute( + config_json: &str, + now_unix_seconds: u64, +) -> Result { + let cfg: TotpConfig = serde_json::from_str(config_json) + .map_err(|e| JsError::new(&format!("totp config: {e}")))?; + let code = compute_totp_code(&cfg, now_unix_seconds) + .map_err(|e| JsError::new(&e.to_string()))?; + let period = cfg.period_seconds as u64; + let expires_at = ((now_unix_seconds / period) + 1) * period; + Ok(TotpCode { code, expires_at }) +} + #[cfg(test)] mod session_tests { use super::*;