Skip to main content

ryu_hardware/
pairing.rs

1//! Pairing: nonce verification + device-token issuance (PROTOCOL.md §5/§6).
2//!
3//! Flow: an unprovisioned device advertises `ryu-pair://<device_id>?n=<nonce>&t=<type>`
4//! (QR for watch/desk, BLE characteristic for necklace). The signed-in mobile app
5//! calls `POST /api/hardware/pair { device_id, pairing_nonce, device_type }`; this
6//! module verifies the nonce, registers the device, and returns a per-device
7//! `device_token` + `node_url`. The app then provisions the device over BLE.
8//!
9//! ## Trust model
10//!
11//! The pairing nonce is generated **on the device** at boot and shown to the user
12//! out-of-band (a QR code on the watch/desk screen, or read over a local BLE GATT
13//! characteristic on the necklace). Possession of the nonce is therefore the
14//! proof that the app is physically near the device. The node does not pre-know
15//! the nonce; it accepts the first pairing call that presents a well-formed nonce
16//! for an *unpaired* device_id, registers the device, and then **burns** that
17//! (device_id, nonce) pair so the same QR can't be replayed to mint a second
18//! token. A device_id that is already paired is rejected (re-pair requires an
19//! explicit revoke first), which prevents a stranger who later sees the QR from
20//! hijacking an in-use device.
21
22use 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/// Why a pairing attempt was rejected (maps to an `error.code` / HTTP status).
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum PairError {
32    /// The nonce was missing/malformed (too short to be a real pairing nonce) or
33    /// has already been consumed (replay).
34    BadNonce,
35    /// The device_id is already paired to this node.
36    AlreadyPaired,
37    /// Internal storage failure.
38    Storage,
39}
40
41impl PairError {
42    /// Stable machine code for the JSON `error.code` field / logs.
43    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    /// Human-readable message for the REST response.
52    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
61/// Minimum accepted nonce length. The firmware [`pairing`] component emits a
62/// 128-bit nonce as hex (32 chars); we accept anything plausibly random to stay
63/// tolerant of encoding, while rejecting empty/trivial values.
64const MIN_NONCE_LEN: usize = 8;
65
66/// Process-global ledger of consumed `(device_id, nonce)` pairs, so a captured QR
67/// cannot be replayed within the lifetime of the node process. (A paired
68/// device_id is also rejected by the store check below, so this mainly guards the
69/// window between a failed insert and a retry, and double-submits.)
70fn 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
75/// Generate a fresh, cryptographically-random per-device Bearer token (256 bits,
76/// hex-encoded). The raw token is returned to the app exactly once (in
77/// [`PairResponse`]); only its hash is persisted (see [`DeviceStore`]).
78pub 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
85/// Generate a stable per-device id with the protocol's class prefix
86/// (`rhw_`/`rhn_`/`rhd_` for watch/necklace/desk). The firmware generates its own
87/// id at first boot; this mirror is used by tests and any node-driven flow.
88pub 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
98/// Verify the pairing nonce and register the device, returning its token and the
99/// node URL the device should connect to.
100///
101/// `node_url` is derived by the caller from the node's reachable address
102/// (tailnet/LAN); the resolution seam is `sidecar::tailscale` (see
103/// `server::hardware_api`).
104pub 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    // Reject a device that is already paired (a stranger who later sees the QR
115    // must not be able to mint a token for an in-use device).
116    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    // Burn the (device_id, nonce) pair — fail if it was already consumed.
123    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
155/// A friendly default device name applied at pairing; the user can rename it via
156/// `PATCH /api/hardware/devices/:id`.
157fn 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        // The issued token verifies against the stored hash.
198        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        // Already paired → rejected even with the same nonce.
224        assert_eq!(
225            pair(&store, &req, "u").await.unwrap_err(),
226            PairError::AlreadyPaired
227        );
228    }
229}