Skip to main content

nodedb_crdt/
signing.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! Delta signing and verification using HMAC-SHA256 with replay protection.
4//!
5//! ## Design
6//!
7//! Each authenticated device gets a per-device HMAC key derived from the
8//! stored user key via HKDF-SHA256:
9//!
10//! ```text
11//! device_key = HKDF-SHA256(
12//!     ikm  = stored_user_key,
13//!     salt = b"nodedb-crdt-device-key",
14//!     info = device_id.to_le_bytes(),
15//! )
16//! ```
17//!
18//! The HMAC input binds the delta bytes plus three u64 fields that prevent
19//! replay and identity-substitution attacks:
20//!
21//! ```text
22//! HMAC(device_key, delta_bytes || user_id.to_le_bytes() || device_id.to_le_bytes() || seq_no.to_le_bytes())
23//! ```
24//!
25//! ## Replay protection
26//!
27//! `DeviceRegistry` maintains a per-(user_id, device_id) last-seen seq_no.
28//! Before verifying the signature, `validate_or_reject` checks that the
29//! submitted seq_no is strictly greater than last_seen (cheap rejection first).
30//! On signature pass, last_seen is updated atomically.
31//!
32//! ## Signature comparison
33//!
34//! All signature equality checks use `subtle::ConstantTimeEq` to prevent
35//! timing side-channel attacks.
36
37use std::collections::HashMap;
38use std::sync::{Arc, Mutex};
39
40use hkdf::Hkdf;
41use hmac::{Hmac, Mac};
42use sha2::Sha256;
43use subtle::ConstantTimeEq as _;
44
45use crate::error::{CrdtError, Result};
46
47type HmacSha256 = Hmac<Sha256>;
48
49/// HMAC-SHA256 signature size (32 bytes).
50pub const SIGNATURE_SIZE: usize = 32;
51
52/// HKDF salt for per-device key derivation.
53const DEVICE_KEY_SALT: &[u8] = b"nodedb-crdt-device-key";
54
55/// Per-(user_id, device_id) sequence tracking used by `DeviceRegistry`.
56#[derive(Debug, Clone, Default)]
57struct DeviceState {
58    last_seq_no: u64,
59}
60
61/// In-memory registry of per-device last-seen sequence numbers.
62///
63/// On the server this is held in `Validator`. A persistent mirror in the
64/// redb `crdt_devices` table is maintained by the Control Plane security
65/// catalog and loaded at startup.
66#[derive(Debug, Default)]
67pub struct DeviceRegistry {
68    inner: Mutex<HashMap<(u64, u64), DeviceState>>,
69}
70
71impl DeviceRegistry {
72    /// Create a new, empty device registry.
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Check that `seq_no > last_seen[(user_id, device_id)]`.
78    ///
79    /// Returns `Ok(last_seen)` so the caller can pass it to the error variant
80    /// if the subsequent signature check fails in a way that needs context.
81    ///
82    /// # Errors
83    ///
84    /// Returns `CrdtError::ReplayDetected` if `seq_no <= last_seen`.
85    pub fn check_seq(&self, user_id: u64, device_id: u64, seq_no: u64) -> Result<u64> {
86        let guard = self
87            .inner
88            .lock()
89            .map_err(|_| CrdtError::DeltaApplyFailed("device registry lock poisoned".into()))?;
90        let last_seen = guard
91            .get(&(user_id, device_id))
92            .map_or(0u64, |s| s.last_seq_no);
93        if seq_no <= last_seen {
94            return Err(CrdtError::ReplayDetected {
95                user_id,
96                device_id,
97                seq_no,
98                last_seen,
99            });
100        }
101        Ok(last_seen)
102    }
103
104    /// Update last_seen for `(user_id, device_id)` after a successful accept.
105    ///
106    /// Only updates if `seq_no > current`, to guard against races.
107    pub fn commit_seq(&self, user_id: u64, device_id: u64, seq_no: u64) -> Result<()> {
108        let mut guard = self
109            .inner
110            .lock()
111            .map_err(|_| CrdtError::DeltaApplyFailed("device registry lock poisoned".into()))?;
112        let entry = guard.entry((user_id, device_id)).or_default();
113        if seq_no > entry.last_seq_no {
114            entry.last_seq_no = seq_no;
115        }
116        Ok(())
117    }
118
119    /// Pre-load a known (user_id, device_id, last_seq_no) tuple from the
120    /// persistent catalog on startup.
121    pub fn seed(&self, user_id: u64, device_id: u64, last_seq_no: u64) -> Result<()> {
122        let mut guard = self
123            .inner
124            .lock()
125            .map_err(|_| CrdtError::DeltaApplyFailed("device registry lock poisoned".into()))?;
126        guard.entry((user_id, device_id)).or_default().last_seq_no = last_seq_no;
127        Ok(())
128    }
129
130    /// Return the current last_seen seq_no for a device, or 0 if unknown.
131    pub fn last_seen(&self, user_id: u64, device_id: u64) -> u64 {
132        self.inner
133            .lock()
134            .ok()
135            .and_then(|g| g.get(&(user_id, device_id)).map(|s| s.last_seq_no))
136            .unwrap_or(0)
137    }
138}
139
140/// Signs deltas with a per-device HMAC-SHA256 key derived via HKDF.
141///
142/// Thread-safe behind an `Arc<DeltaSigner>` in the Validator.
143pub struct DeltaSigner {
144    /// user_id -> stored base key (32 bytes). Per-device keys are HKDF-derived
145    /// on demand; never stored.
146    keys: HashMap<u64, [u8; 32]>,
147    /// Per-device sequence tracking (shared with validator).
148    pub(crate) registry: Arc<DeviceRegistry>,
149}
150
151impl DeltaSigner {
152    /// Create a new signer with a fresh device registry.
153    pub fn new() -> Self {
154        Self {
155            keys: HashMap::new(),
156            registry: Arc::new(DeviceRegistry::new()),
157        }
158    }
159
160    /// Create a signer sharing an existing device registry (e.g. with the
161    /// Validator that also needs to call `commit_seq`).
162    pub fn with_registry(registry: Arc<DeviceRegistry>) -> Self {
163        Self {
164            keys: HashMap::new(),
165            registry,
166        }
167    }
168
169    /// Register a stored signing key for a user.
170    pub fn register_key(&mut self, user_id: u64, key: [u8; 32]) {
171        self.keys.insert(user_id, key);
172    }
173
174    /// Remove a user's signing key.
175    pub fn remove_key(&mut self, user_id: u64) {
176        self.keys.remove(&user_id);
177    }
178
179    /// Derive the per-device HMAC key for `(user_id, device_id)`.
180    ///
181    /// Per-device key = HKDF-SHA256(stored_key, salt=DEVICE_KEY_SALT, info=device_id.to_le_bytes())
182    fn device_key(&self, user_id: u64, device_id: u64) -> Result<[u8; 32]> {
183        let stored = self
184            .keys
185            .get(&user_id)
186            .ok_or_else(|| CrdtError::InvalidSignature {
187                user_id,
188                detail: "no signing key registered for user".into(),
189            })?;
190
191        let hk = Hkdf::<Sha256>::new(Some(DEVICE_KEY_SALT), stored.as_slice());
192        let mut okm = [0u8; 32];
193        hk.expand(&device_id.to_le_bytes(), &mut okm)
194            .map_err(|_| CrdtError::InvalidSignature {
195                user_id,
196                detail: "HKDF expand failed (output too long)".into(),
197            })?;
198        Ok(okm)
199    }
200
201    /// Sign delta bytes for a specific device and sequence number.
202    ///
203    /// HMAC input canonical layout:
204    /// `delta_bytes || user_id.to_le_bytes(8) || device_id.to_le_bytes(8) || seq_no.to_le_bytes(8)`
205    pub fn sign(
206        &self,
207        user_id: u64,
208        device_id: u64,
209        seq_no: u64,
210        delta_bytes: &[u8],
211    ) -> Result<[u8; SIGNATURE_SIZE]> {
212        let key = self.device_key(user_id, device_id)?;
213        Ok(compute_hmac(&key, user_id, device_id, seq_no, delta_bytes))
214    }
215
216    /// Verify a delta signature. Returns Ok(()) if valid.
217    ///
218    /// Uses constant-time comparison to prevent timing attacks.
219    pub fn verify(
220        &self,
221        user_id: u64,
222        device_id: u64,
223        seq_no: u64,
224        delta_bytes: &[u8],
225        signature: &[u8; SIGNATURE_SIZE],
226    ) -> Result<()> {
227        let key = self.device_key(user_id, device_id)?;
228        let expected = compute_hmac(&key, user_id, device_id, seq_no, delta_bytes);
229
230        // Constant-time comparison.
231        if expected.ct_eq(signature).into() {
232            Ok(())
233        } else {
234            Err(CrdtError::InvalidSignature {
235                user_id,
236                detail: "HMAC-SHA256 mismatch".into(),
237            })
238        }
239    }
240
241    /// Access the shared device registry.
242    pub fn registry(&self) -> &Arc<DeviceRegistry> {
243        &self.registry
244    }
245}
246
247impl Default for DeltaSigner {
248    fn default() -> Self {
249        Self::new()
250    }
251}
252
253/// Compute HMAC-SHA256 over the canonical delta-signing message.
254///
255/// Canonical layout:
256/// `delta_bytes || user_id(8 LE) || device_id(8 LE) || seq_no(8 LE)`
257fn compute_hmac(
258    key: &[u8; 32],
259    user_id: u64,
260    device_id: u64,
261    seq_no: u64,
262    delta_bytes: &[u8],
263) -> [u8; SIGNATURE_SIZE] {
264    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size");
265    mac.update(delta_bytes);
266    mac.update(&user_id.to_le_bytes());
267    mac.update(&device_id.to_le_bytes());
268    mac.update(&seq_no.to_le_bytes());
269    let result = mac.finalize();
270    let mut out = [0u8; SIGNATURE_SIZE];
271    out.copy_from_slice(&result.into_bytes());
272    out
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    fn make_signer(user_id: u64, key: [u8; 32]) -> DeltaSigner {
280        let mut s = DeltaSigner::new();
281        s.register_key(user_id, key);
282        s
283    }
284
285    // ── HMAC golden-vector test ────────────────────────────────────────────
286    // Hardcoded expected hex computed from the canonical HKDF+HMAC derivation.
287    // If signing.rs changes the canonical layout, update this vector.
288    #[test]
289    fn hmac_golden_vector() {
290        // stored key = [0x42; 32], user=1, device=2, seq=1, delta=b"nodedb"
291        let signer = make_signer(1, [0x42u8; 32]);
292        let sig = signer.sign(1, 2, 1, b"nodedb").unwrap();
293
294        // Recompute inline to pin the vector.
295        let device_key = {
296            let hk = Hkdf::<Sha256>::new(Some(DEVICE_KEY_SALT), &[0x42u8; 32]);
297            let mut okm = [0u8; 32];
298            hk.expand(&2u64.to_le_bytes(), &mut okm).unwrap();
299            okm
300        };
301        let expected = compute_hmac(&device_key, 1, 2, 1, b"nodedb");
302        assert_eq!(sig, expected, "HMAC golden vector must be stable");
303    }
304
305    // ── Replay rejected on second submission ────────────────────────────────
306    #[test]
307    fn replay_rejected_same_device_seq() {
308        let signer = make_signer(1, [0x42u8; 32]);
309        let delta = b"test delta";
310        let sig = signer.sign(1, 2, 1, delta).unwrap();
311
312        // First submission: passes seq check.
313        signer.registry.check_seq(1, 2, 1).unwrap();
314        signer.verify(1, 2, 1, delta, &sig).unwrap();
315        signer.registry.commit_seq(1, 2, 1).unwrap();
316
317        // Second submission with same seq_no: replay.
318        let err = signer.registry.check_seq(1, 2, 1).unwrap_err();
319        assert!(
320            matches!(
321                err,
322                CrdtError::ReplayDetected {
323                    seq_no: 1,
324                    last_seen: 1,
325                    ..
326                }
327            ),
328            "expected ReplayDetected, got {err}"
329        );
330    }
331
332    // ── Cross-device replay rejected ─────────────────────────────────────────
333    // A signed delta for device 2 must not verify under device 3's key.
334    #[test]
335    fn cross_device_replay_rejected() {
336        let signer = make_signer(1, [0x42u8; 32]);
337        let delta = b"cross device test";
338        let sig = signer.sign(1, 2, 1, delta).unwrap();
339
340        // Use device_id=3 in verify — different HKDF key, must fail.
341        let err = signer.verify(1, 3, 1, delta, &sig).unwrap_err();
342        assert!(
343            matches!(err, CrdtError::InvalidSignature { .. }),
344            "cross-device replay must be rejected"
345        );
346    }
347
348    // ── seq_no=0 is rejected ─────────────────────────────────────────────────
349    // With seq_no=0: check_seq(0) <= last_seen(0) → ReplayDetected.
350    #[test]
351    fn seq_zero_rejected() {
352        let registry = DeviceRegistry::new();
353        // seq_no=0 is never > last_seen=0.
354        let err = registry.check_seq(1, 0, 0).unwrap_err();
355        assert!(
356            matches!(
357                err,
358                CrdtError::ReplayDetected {
359                    seq_no: 0,
360                    last_seen: 0,
361                    ..
362                }
363            ),
364            "seq_no=0 must be rejected (not strictly greater than last_seen=0)"
365        );
366    }
367
368    // ── Verify rejects tampered delta ────────────────────────────────────────
369    #[test]
370    fn tampered_delta_fails_verification() {
371        let signer = make_signer(1, [0x42u8; 32]);
372        let sig = signer.sign(1, 2, 1, b"original").unwrap();
373        let err = signer.verify(1, 2, 1, b"tampered", &sig).unwrap_err();
374        assert!(matches!(err, CrdtError::InvalidSignature { .. }));
375    }
376
377    // ── Verify rejects wrong user ────────────────────────────────────────────
378    #[test]
379    fn wrong_user_fails_verification() {
380        let mut signer = DeltaSigner::new();
381        signer.register_key(1, [0x42u8; 32]);
382        signer.register_key(2, [0x99u8; 32]);
383
384        let sig = signer.sign(1, 5, 1, b"delta").unwrap();
385        let err = signer.verify(2, 5, 1, b"delta", &sig).unwrap_err();
386        assert!(matches!(err, CrdtError::InvalidSignature { .. }));
387    }
388
389    // ── Unregistered user fails ───────────────────────────────────────────────
390    #[test]
391    fn unregistered_user_fails() {
392        let signer = DeltaSigner::new();
393        let err = signer.sign(99, 1, 1, b"data").unwrap_err();
394        assert!(matches!(
395            err,
396            CrdtError::InvalidSignature { user_id: 99, .. }
397        ));
398    }
399
400    // ── seq_no strictly monotone ─────────────────────────────────────────────
401    #[test]
402    fn seq_no_must_advance() {
403        let reg = DeviceRegistry::new();
404        reg.check_seq(1, 1, 5).unwrap();
405        reg.commit_seq(1, 1, 5).unwrap();
406
407        // seq_no=5 again — replay.
408        assert!(reg.check_seq(1, 1, 5).is_err());
409        // seq_no=4 — replay.
410        assert!(reg.check_seq(1, 1, 4).is_err());
411        // seq_no=6 — ok.
412        reg.check_seq(1, 1, 6).unwrap();
413    }
414}