feat(wasm): attachment / generator / totp / imgsecret / id bridges

Also ports TOTP RFC 6238 compute to relicario-core::item_types::totp
so native + CLI + WASM share one implementation (audit H5: CSPRNG
via core's Uniform-sampling generator).

Adds hmac = "0.12" and sha1 = "0.10" to relicario-core deps to support
HOTP/TOTP HMAC with Sha1/Sha256/Sha512. RFC 6238 test vector (t=59,
SHA-1, 8 digits) passes: "94287082".
This commit is contained in:
adlee-was-taken
2026-04-20 17:39:45 -04:00
parent fac2e49cf1
commit 92b9e64ef9
4 changed files with 182 additions and 1 deletions

View File

@@ -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"] }

View File

@@ -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")]

View File

@@ -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<String> {
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<u8> = match config.algorithm {
TotpAlgorithm::Sha1 => {
let mut mac = Hmac::<HmacSha1>::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::<HmacSha256>::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::<HmacSha512>::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::*;

View File

@@ -115,6 +115,123 @@ pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result<V
.map_err(|e| JsError::new(&e.to_string()))
}
// ── Task 20: attachment / generator / imgsecret / ID / TOTP bridges ─────────
use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId};
#[wasm_bindgen]
pub struct EncryptedAttachment {
aid: String,
bytes: Vec<u8>,
}
#[wasm_bindgen]
impl EncryptedAttachment {
#[wasm_bindgen(getter)] pub fn aid(&self) -> String { self.aid.clone() }
#[wasm_bindgen(getter)] pub fn bytes(&self) -> Vec<u8> { self.bytes.clone() }
}
#[wasm_bindgen]
pub fn attachment_encrypt(
handle: &SessionHandle,
plaintext: &[u8],
max_bytes: u64,
) -> Result<EncryptedAttachment, JsError> {
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<Vec<u8>, 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<String, JsError> {
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<String, JsError> {
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<JsValue, JsError> {
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<Vec<u8>, 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<Vec<u8>, 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<TotpCode, JsError> {
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::*;