1use std::collections::HashSet;
23use std::sync::Mutex;
24use std::sync::OnceLock;
25
26use super::protocol::{PairRequest, PairResponse};
27use super::store::{hash_token, DeviceRecord, DeviceStore};
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum PairError {
32 BadNonce,
35 AlreadyPaired,
37 Storage,
39}
40
41impl PairError {
42 pub fn code(self) -> &'static str {
44 match self {
45 PairError::BadNonce => "bad_nonce",
46 PairError::AlreadyPaired => "already_paired",
47 PairError::Storage => "storage",
48 }
49 }
50
51 pub fn message(self) -> &'static str {
53 match self {
54 PairError::BadNonce => "pairing nonce missing, malformed, or already used",
55 PairError::AlreadyPaired => "device already paired (revoke it first to re-pair)",
56 PairError::Storage => "device registry storage error",
57 }
58 }
59}
60
61const MIN_NONCE_LEN: usize = 8;
65
66fn consumed_nonces() -> &'static Mutex<HashSet<String>> {
71 static LEDGER: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
72 LEDGER.get_or_init(|| Mutex::new(HashSet::new()))
73}
74
75pub fn generate_device_token() -> String {
79 use rand::RngCore;
80 let mut bytes = [0u8; 32];
81 rand::thread_rng().fill_bytes(&mut bytes);
82 format!("rht_{}", hex::encode(bytes))
83}
84
85pub fn generate_device_id(device_type: super::protocol::DeviceType) -> String {
89 use super::protocol::DeviceType;
90 let prefix = match device_type {
91 DeviceType::Watch => "rhw",
92 DeviceType::Necklace => "rhn",
93 DeviceType::Desk => "rhd",
94 };
95 format!("{prefix}_{}", uuid::Uuid::new_v4().simple())
96}
97
98pub async fn pair(
105 store: &DeviceStore,
106 req: &PairRequest,
107 node_url: &str,
108) -> Result<PairResponse, PairError> {
109 let nonce = req.pairing_nonce.trim();
110 if nonce.len() < MIN_NONCE_LEN {
111 return Err(PairError::BadNonce);
112 }
113
114 match store.get(&req.device_id).await {
117 Ok(Some(_)) => return Err(PairError::AlreadyPaired),
118 Ok(None) => {}
119 Err(_) => return Err(PairError::Storage),
120 }
121
122 let ledger_key = format!("{}:{nonce}", req.device_id);
124 {
125 let mut consumed = consumed_nonces().lock().unwrap();
126 if !consumed.insert(ledger_key) {
127 return Err(PairError::BadNonce);
128 }
129 }
130
131 let token = generate_device_token();
132 let now = chrono::Utc::now().timestamp_millis();
133 let record = DeviceRecord {
134 device_id: req.device_id.clone(),
135 device_type: req.device_type,
136 name: default_name(req.device_type),
137 token_hash: hash_token(&token),
138 last_seen: None,
139 battery_pct: None,
140 prefs: serde_json::json!({}),
141 ambient_meeting_id: None,
142 created_at: now,
143 };
144
145 if store.insert(record).await.is_err() {
146 return Err(PairError::Storage);
147 }
148
149 Ok(PairResponse {
150 device_token: token,
151 node_url: node_url.to_string(),
152 })
153}
154
155fn default_name(device_type: super::protocol::DeviceType) -> String {
158 use super::protocol::DeviceType;
159 match device_type {
160 DeviceType::Watch => "Ryu Watch",
161 DeviceType::Necklace => "Ryu Necklace",
162 DeviceType::Desk => "Ryu Desk",
163 }
164 .to_string()
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::protocol::DeviceType;
171
172 fn temp_store() -> DeviceStore {
173 let dir = std::env::temp_dir().join(format!("ryu-hw-pair-{}", uuid::Uuid::new_v4()));
174 DeviceStore::open(dir.join("hardware.db")).expect("open")
175 }
176
177 #[test]
178 fn token_and_id_have_prefixes() {
179 assert!(generate_device_token().starts_with("rht_"));
180 assert!(generate_device_id(DeviceType::Watch).starts_with("rhw_"));
181 assert!(generate_device_id(DeviceType::Necklace).starts_with("rhn_"));
182 }
183
184 #[tokio::test]
185 async fn pair_registers_and_returns_token() {
186 let store = temp_store();
187 let req = PairRequest {
188 device_id: "rhw_abc".into(),
189 pairing_nonce: "0123456789abcdef".into(),
190 device_type: DeviceType::Watch,
191 };
192 let resp = pair(&store, &req, "ws://node.local/api/hardware/ws")
193 .await
194 .expect("pairs");
195 assert!(resp.device_token.starts_with("rht_"));
196 assert_eq!(resp.node_url, "ws://node.local/api/hardware/ws");
197 assert!(store
199 .verify_token("rhw_abc", &resp.device_token)
200 .await
201 .unwrap());
202 }
203
204 #[tokio::test]
205 async fn rejects_short_nonce_and_replay_and_double_pair() {
206 let store = temp_store();
207 let short = PairRequest {
208 device_id: "rhw_x".into(),
209 pairing_nonce: "abc".into(),
210 device_type: DeviceType::Watch,
211 };
212 assert_eq!(
213 pair(&store, &short, "u").await.unwrap_err(),
214 PairError::BadNonce
215 );
216
217 let req = PairRequest {
218 device_id: "rhw_y".into(),
219 pairing_nonce: "ffffffffffffffff".into(),
220 device_type: DeviceType::Watch,
221 };
222 assert!(pair(&store, &req, "u").await.is_ok());
223 assert_eq!(
225 pair(&store, &req, "u").await.unwrap_err(),
226 PairError::AlreadyPaired
227 );
228 }
229}