use argon2::password_hash::{Salt, SaltString};
use argon2::{Argon2, PasswordHasher};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use once_cell::sync::Lazy;
use rhai::Engine;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::{Mutex, RwLock};
use xxhash_rust::xxh3::xxh3_64;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Default)]
pub struct HashingRuntimeConfig {
pub verbose: u8,
pub use_emoji: bool,
}
static RUNTIME_CONFIG: Lazy<RwLock<HashingRuntimeConfig>> =
Lazy::new(|| RwLock::new(HashingRuntimeConfig::default()));
pub fn set_runtime_config(config: HashingRuntimeConfig) {
let mut guard = RUNTIME_CONFIG
.write()
.expect("hashing runtime config poisoned");
*guard = config;
}
fn log_pseudonym_init(message: &str) {
let config = RUNTIME_CONFIG
.read()
.expect("hashing runtime config poisoned");
if config.verbose >= 2 {
let prefix = if config.use_emoji { "🔹" } else { "kelora:" };
eprintln!("{} {}", prefix, message);
}
}
static MASTER_KEY: Lazy<MasterKeyState> = Lazy::new(|| {
match std::env::var("KELORA_SECRET") {
Ok(secret) if !secret.is_empty() => {
match derive_master_key_from_secret(&secret) {
Ok(key) => {
log_pseudonym_init("pseudonym: ON (stable; KELORA_SECRET)");
MasterKeyState::Stable(key)
}
Err(e) => {
eprintln!("kelora: pseudonym init failed: {}", e);
std::process::exit(1);
}
}
}
Ok(_) => {
eprintln!("kelora: KELORA_SECRET must not be empty");
std::process::exit(1);
}
Err(_) => {
let mut key = [0u8; 32];
for byte in &mut key {
*byte = fastrand::u8(..);
}
log_pseudonym_init("pseudonym: ON (ephemeral; not stable)");
MasterKeyState::Ephemeral(key)
}
}
});
static DOMAIN_KEYS: Lazy<Mutex<HashMap<String, [u8; 32]>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
enum MasterKeyState {
Stable([u8; 32]),
Ephemeral([u8; 32]),
}
impl MasterKeyState {
fn as_bytes(&self) -> &[u8; 32] {
match self {
MasterKeyState::Stable(k) => k,
MasterKeyState::Ephemeral(k) => k,
}
}
}
fn derive_master_key_from_secret(secret: &str) -> Result<[u8; 32], String> {
let argon2 = Argon2::new(
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
argon2::Params::new(
64 * 1024, 3, 1, Some(32), )
.map_err(|e| format!("Argon2 params error: {}", e))?,
);
let salt = SaltString::encode_b64(b"kelora:v1:master")
.map_err(|e| format!("Salt encoding error: {}", e))?;
let hash = argon2
.hash_password(secret.as_bytes(), Salt::try_from(salt.as_str()).unwrap())
.map_err(|e| format!("Argon2 hashing error: {}", e))?;
let hash_bytes = hash
.hash
.ok_or_else(|| "Argon2 produced no hash".to_string())?;
let mut key = [0u8; 32];
key.copy_from_slice(hash_bytes.as_bytes());
Ok(key)
}
fn derive_domain_key(domain: &str) -> Result<[u8; 32], String> {
{
let cache = DOMAIN_KEYS.lock().unwrap();
if let Some(key) = cache.get(domain) {
return Ok(*key);
}
}
let master = MASTER_KEY.as_bytes();
let info = format!("kelora:v1:{}", domain);
let hkdf = Hkdf::<Sha256>::new(None, master);
let mut okm = [0u8; 32];
hkdf.expand(info.as_bytes(), &mut okm)
.map_err(|e| format!("HKDF expansion error: {}", e))?;
{
let mut cache = DOMAIN_KEYS.lock().unwrap();
cache.insert(domain.to_string(), okm);
}
Ok(okm)
}
fn pseudonym_impl(value: &str, domain: &str) -> Result<String, Box<rhai::EvalAltResult>> {
if domain.is_empty() {
return Err("pseudonym: domain must be non-empty".into());
}
let _ = MASTER_KEY.as_bytes();
let domain_key = derive_domain_key(domain)
.map_err(|e| format!("pseudonym: domain key derivation failed: {}", e))?;
let mut mac =
HmacSha256::new_from_slice(&domain_key).map_err(|e| format!("HMAC init error: {}", e))?;
mac.update(domain.as_bytes());
mac.update(value.as_bytes());
let result = mac.finalize();
let tag = result.into_bytes();
let encoded = URL_SAFE_NO_PAD.encode(tag);
Ok(encoded[..24].to_string())
}
fn bucket_impl(value: &str) -> i64 {
xxh3_64(value.as_bytes()) as i64
}
fn hash_impl(value: &str, algo: &str) -> Result<String, Box<rhai::EvalAltResult>> {
let algo_lower = algo.to_lowercase();
match algo_lower.as_str() {
"sha256" => {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
Ok(hex::encode(hasher.finalize()))
}
"xxh3" => {
let hash = xxh3_64(value.as_bytes());
Ok(format!("{:016x}", hash))
}
_ => Err(format!("Unknown hash algorithm '{}'. Supported: sha256, xxh3", algo).into()),
}
}
fn hash_default_impl(value: &str) -> Result<String, Box<rhai::EvalAltResult>> {
hash_impl(value, "sha256")
}
pub fn register_functions(engine: &mut Engine) {
engine.register_fn("bucket", bucket_impl);
engine.register_fn("hash", hash_default_impl);
engine.register_fn("hash", hash_impl);
engine.register_fn("pseudonym", pseudonym_impl);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bucket() {
let result1 = bucket_impl("test");
let result2 = bucket_impl("test");
let result3 = bucket_impl("other");
assert_eq!(result1, result2);
assert_ne!(result1, result3);
}
#[test]
fn test_hash_sha256() {
let result = hash_impl("hello", "sha256").unwrap();
assert_eq!(
result,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn test_hash_xxh3() {
let result = hash_impl("hello", "xxh3").unwrap();
assert_eq!(result.len(), 16);
assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_hash_unknown_algo() {
let result = hash_impl("hello", "unknown");
assert!(result.is_err());
}
#[test]
fn test_hash_default() {
let result = hash_default_impl("hello").unwrap();
assert_eq!(
result,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn test_pseudonym_empty_domain() {
let result = pseudonym_impl("value", "");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("domain must be non-empty"));
}
#[test]
fn test_pseudonym_deterministic() {
let result1 = pseudonym_impl("user123", "kelora:v1:email").unwrap();
let result2 = pseudonym_impl("user123", "kelora:v1:email").unwrap();
assert_eq!(result1, result2);
assert_eq!(result1.len(), 24);
}
#[test]
fn test_pseudonym_domain_separation() {
let result1 = pseudonym_impl("user123", "kelora:v1:email").unwrap();
let result2 = pseudonym_impl("user123", "kelora:v1:ip").unwrap();
assert_ne!(result1, result2);
}
#[test]
fn test_pseudonym_different_values() {
let result1 = pseudonym_impl("user123", "kelora:v1:email").unwrap();
let result2 = pseudonym_impl("user456", "kelora:v1:email").unwrap();
assert_ne!(result1, result2);
}
#[test]
fn test_pseudonym_output_format() {
let result = pseudonym_impl("test", "kelora:v1:test").unwrap();
assert_eq!(result.len(), 24);
assert!(result
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
assert!(!result.contains('='));
}
#[test]
fn test_rhai_integration() {
let mut engine = rhai::Engine::new();
register_functions(&mut engine);
let result: i64 = engine.eval(r#"bucket("test")"#).unwrap();
assert_eq!(result, bucket_impl("test"));
let result: String = engine.eval(r#"hash("hello")"#).unwrap();
assert_eq!(
result,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
let result: String = engine.eval(r#"hash("hello", "xxh3")"#).unwrap();
assert_eq!(result.len(), 16);
let result: String = engine
.eval(r#"pseudonym("user123", "kelora:v1:email")"#)
.unwrap();
assert_eq!(result.len(), 24);
let result = engine.eval::<String>(r#"pseudonym("user123", "")"#);
assert!(result.is_err());
}
}