use std::collections::HashSet;
use std::sync::Mutex;
use std::sync::OnceLock;
use super::protocol::{PairRequest, PairResponse};
use super::store::{hash_token, DeviceRecord, DeviceStore};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PairError {
BadNonce,
AlreadyPaired,
Storage,
}
impl PairError {
pub fn code(self) -> &'static str {
match self {
PairError::BadNonce => "bad_nonce",
PairError::AlreadyPaired => "already_paired",
PairError::Storage => "storage",
}
}
pub fn message(self) -> &'static str {
match self {
PairError::BadNonce => "pairing nonce missing, malformed, or already used",
PairError::AlreadyPaired => "device already paired (revoke it first to re-pair)",
PairError::Storage => "device registry storage error",
}
}
}
const MIN_NONCE_LEN: usize = 8;
fn consumed_nonces() -> &'static Mutex<HashSet<String>> {
static LEDGER: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
LEDGER.get_or_init(|| Mutex::new(HashSet::new()))
}
pub fn generate_device_token() -> String {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
format!("rht_{}", hex::encode(bytes))
}
pub fn generate_device_id(device_type: super::protocol::DeviceType) -> String {
use super::protocol::DeviceType;
let prefix = match device_type {
DeviceType::Watch => "rhw",
DeviceType::Necklace => "rhn",
DeviceType::Desk => "rhd",
};
format!("{prefix}_{}", uuid::Uuid::new_v4().simple())
}
pub async fn pair(
store: &DeviceStore,
req: &PairRequest,
node_url: &str,
) -> Result<PairResponse, PairError> {
let nonce = req.pairing_nonce.trim();
if nonce.len() < MIN_NONCE_LEN {
return Err(PairError::BadNonce);
}
match store.get(&req.device_id).await {
Ok(Some(_)) => return Err(PairError::AlreadyPaired),
Ok(None) => {}
Err(_) => return Err(PairError::Storage),
}
let ledger_key = format!("{}:{nonce}", req.device_id);
{
let mut consumed = consumed_nonces().lock().unwrap();
if !consumed.insert(ledger_key) {
return Err(PairError::BadNonce);
}
}
let token = generate_device_token();
let now = chrono::Utc::now().timestamp_millis();
let record = DeviceRecord {
device_id: req.device_id.clone(),
device_type: req.device_type,
name: default_name(req.device_type),
token_hash: hash_token(&token),
last_seen: None,
battery_pct: None,
prefs: serde_json::json!({}),
ambient_meeting_id: None,
created_at: now,
};
if store.insert(record).await.is_err() {
return Err(PairError::Storage);
}
Ok(PairResponse {
device_token: token,
node_url: node_url.to_string(),
})
}
fn default_name(device_type: super::protocol::DeviceType) -> String {
use super::protocol::DeviceType;
match device_type {
DeviceType::Watch => "Ryu Watch",
DeviceType::Necklace => "Ryu Necklace",
DeviceType::Desk => "Ryu Desk",
}
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::DeviceType;
fn temp_store() -> DeviceStore {
let dir = std::env::temp_dir().join(format!("ryu-hw-pair-{}", uuid::Uuid::new_v4()));
DeviceStore::open(dir.join("hardware.db")).expect("open")
}
#[test]
fn token_and_id_have_prefixes() {
assert!(generate_device_token().starts_with("rht_"));
assert!(generate_device_id(DeviceType::Watch).starts_with("rhw_"));
assert!(generate_device_id(DeviceType::Necklace).starts_with("rhn_"));
}
#[tokio::test]
async fn pair_registers_and_returns_token() {
let store = temp_store();
let req = PairRequest {
device_id: "rhw_abc".into(),
pairing_nonce: "0123456789abcdef".into(),
device_type: DeviceType::Watch,
};
let resp = pair(&store, &req, "ws://node.local/api/hardware/ws")
.await
.expect("pairs");
assert!(resp.device_token.starts_with("rht_"));
assert_eq!(resp.node_url, "ws://node.local/api/hardware/ws");
assert!(store
.verify_token("rhw_abc", &resp.device_token)
.await
.unwrap());
}
#[tokio::test]
async fn rejects_short_nonce_and_replay_and_double_pair() {
let store = temp_store();
let short = PairRequest {
device_id: "rhw_x".into(),
pairing_nonce: "abc".into(),
device_type: DeviceType::Watch,
};
assert_eq!(
pair(&store, &short, "u").await.unwrap_err(),
PairError::BadNonce
);
let req = PairRequest {
device_id: "rhw_y".into(),
pairing_nonce: "ffffffffffffffff".into(),
device_type: DeviceType::Watch,
};
assert!(pair(&store, &req, "u").await.is_ok());
assert_eq!(
pair(&store, &req, "u").await.unwrap_err(),
PairError::AlreadyPaired
);
}
}