Skip to main content

matter_controller/
snapshot.rs

1//! Versioned TLV serialization of [`ControllerState`].
2//!
3//! Layout (all members context-tagged):
4//! ```text
5//! root: Structure { C0: version(u8), C1: Array[fabric] }
6//! fabric: Structure {
7//!   C0: fabric_id(uint), C1: ipk(bytes16), C2: rcac_cert(bytes,TLV),
8//!   C3: rcac_pkcs8(bytes), C4: commissioner(struct), C5: Array[device],
9//!   C6: Array[group_key_set] (optional — absent in v1 snapshots written
10//!       before group-key support; defaults to empty on read),
11//!   C7: outbound_group_counter(uint, optional — same default rule)
12//! }
13//! commissioner: Structure { C0: node_id(uint), C1: op_pkcs8(bytes), C2: noc(bytes,TLV) }
14//! device: Structure {
15//!   C0: node_id(uint), C1: peer_noc_public_key(bytes65),
16//!   C2: resumption_record(bytes, optional), C3: last_known_addr(utf8, optional)
17//! }
18//! group_key_set: Structure {
19//!   C0: key_set_id(uint), C1: epoch_key(bytes16), C2: epoch_start_time(uint)
20//! }
21//! ```
22//!
23//! ## Backward compatibility
24//!
25//! C6 and C7 are optional tags: a v1 snapshot without them (written by an
26//! older binary) still deserializes cleanly — `group_keys` defaults to
27//! `Vec::new()` and `outbound_group_counter` to `0`.  No version bump is
28//! needed because the tag-keyed deserializer simply treats absent tags as
29//! defaults.
30
31use matter_cert::MatterCertificate;
32use matter_codec::{Tag, TlvWriter, Value};
33
34use crate::error::Error;
35use crate::state::{
36    CommissionerIdentity, ControllerState, DeviceEntry, FabricEntry, GroupKeySetConfig,
37};
38
39/// Current snapshot schema version.
40pub const SNAPSHOT_VERSION: u8 = 1;
41
42/// Serialize controller state into an opaque TLV blob.
43///
44/// # Errors
45///
46/// Returns [`Error::Cert`] if a certificate fails to serialize, or
47/// [`Error::Codec`] if TLV encoding fails.
48pub fn serialize(state: &ControllerState) -> Result<Vec<u8>, Error> {
49    let mut fabrics = Vec::with_capacity(state.fabrics.len());
50    for f in &state.fabrics {
51        fabrics.push(fabric_to_value(f)?);
52    }
53    let root = Value::Structure(vec![
54        (Tag::Context(0), Value::Uint(u64::from(SNAPSHOT_VERSION))),
55        (Tag::Context(1), Value::Array(fabrics)),
56    ]);
57
58    let mut out = Vec::new();
59    let mut w = TlvWriter::new(&mut out);
60    w.write_value(Tag::Anonymous, &root)?;
61    Ok(out)
62}
63
64fn fabric_to_value(f: &FabricEntry) -> Result<Value, Error> {
65    let devices = f.devices.iter().map(device_to_value).collect();
66    let group_keys: Vec<Value> = f.group_keys.iter().map(group_key_to_value).collect();
67    let icd_clients: Vec<Value> = f
68        .icd_clients
69        .iter()
70        .map(icd_registration_to_value)
71        .collect();
72    Ok(Value::Structure(vec![
73        (Tag::Context(0), Value::Uint(f.fabric_id)),
74        (Tag::Context(1), Value::Bytes(f.ipk.to_vec())),
75        (Tag::Context(2), Value::Bytes(f.rcac_cert.to_tlv()?)),
76        (Tag::Context(3), Value::Bytes(f.rcac_pkcs8.clone())),
77        (Tag::Context(4), commissioner_to_value(&f.commissioner)?),
78        (Tag::Context(5), Value::Array(devices)),
79        (Tag::Context(6), Value::Array(group_keys)),
80        (
81            Tag::Context(7),
82            Value::Uint(u64::from(f.outbound_group_counter)),
83        ),
84        (Tag::Context(8), Value::Array(icd_clients)),
85    ]))
86}
87
88fn icd_registration_to_value(r: &crate::icd::IcdRegistration) -> Value {
89    Value::Structure(vec![
90        (Tag::Context(0), Value::Uint(r.node_id)),
91        (Tag::Context(1), Value::Uint(r.check_in_node_id)),
92        (Tag::Context(2), Value::Uint(r.monitored_subject)),
93        (Tag::Context(3), Value::Bytes(r.key.to_vec())),
94        (Tag::Context(4), Value::Uint(u64::from(r.start_counter))),
95    ])
96}
97
98fn group_key_to_value(k: &GroupKeySetConfig) -> Value {
99    Value::Structure(vec![
100        (Tag::Context(0), Value::Uint(u64::from(k.key_set_id))),
101        (Tag::Context(1), Value::Bytes(k.epoch_key.to_vec())),
102        (Tag::Context(2), Value::Uint(k.epoch_start_time)),
103    ])
104}
105
106fn commissioner_to_value(c: &CommissionerIdentity) -> Result<Value, Error> {
107    Ok(Value::Structure(vec![
108        (Tag::Context(0), Value::Uint(c.node_id)),
109        (Tag::Context(1), Value::Bytes(c.operational_pkcs8.clone())),
110        (Tag::Context(2), Value::Bytes(c.noc.to_tlv()?)),
111    ]))
112}
113
114fn device_to_value(d: &DeviceEntry) -> Value {
115    let mut members = vec![
116        (Tag::Context(0), Value::Uint(d.node_id)),
117        (
118            Tag::Context(1),
119            Value::Bytes(d.peer_noc_public_key.to_vec()),
120        ),
121    ];
122    if let Some(rr) = &d.resumption_record {
123        members.push((Tag::Context(2), Value::Bytes(rr.clone())));
124    }
125    if let Some(addr) = &d.last_known_addr {
126        members.push((Tag::Context(3), Value::Utf8(addr.clone())));
127    }
128    Value::Structure(members)
129}
130
131/// Deserialize a snapshot blob into [`ControllerState`].
132///
133/// # Errors
134///
135/// Returns [`Error::Snapshot`] if the structure or version is invalid,
136/// [`Error::Codec`] on TLV decode failure, or [`Error::Cert`] if an
137/// embedded certificate fails to parse.
138pub fn deserialize(bytes: &[u8]) -> Result<ControllerState, Error> {
139    use matter_codec::TlvReader;
140
141    let mut r = TlvReader::new(bytes);
142    let (_tag, value) = r.read_value()?;
143    let root = as_struct(&value)?;
144
145    let version = get_uint(root, 0)?;
146    if version != u64::from(SNAPSHOT_VERSION) {
147        return Err(Error::Snapshot(format!(
148            "unsupported snapshot version {version}"
149        )));
150    }
151
152    let fabrics_val =
153        get(root, 1).ok_or_else(|| Error::Snapshot("missing fabrics array".into()))?;
154    let mut fabrics = Vec::new();
155    for fv in as_array(fabrics_val)? {
156        fabrics.push(fabric_from_value(fv)?);
157    }
158    Ok(ControllerState { fabrics })
159}
160
161fn fabric_from_value(v: &Value) -> Result<FabricEntry, Error> {
162    let m = as_struct(v)?;
163    let commissioner_val =
164        get(m, 4).ok_or_else(|| Error::Snapshot("missing commissioner".into()))?;
165    let devices_val = get(m, 5).ok_or_else(|| Error::Snapshot("missing devices array".into()))?;
166    let mut devices = Vec::new();
167    for dv in as_array(devices_val)? {
168        devices.push(device_from_value(dv)?);
169    }
170
171    // t6 / t7 are optional (absent in v1 snapshots written before group-key
172    // support was added).  Default to empty / 0 when missing — this keeps old
173    // stores loadable without a version bump.
174    let group_keys = match get(m, 6) {
175        Some(arr) => {
176            let mut keys = Vec::new();
177            for kv in as_array(arr)? {
178                keys.push(group_key_from_value(kv)?);
179            }
180            keys
181        }
182        None => Vec::new(),
183    };
184    let outbound_group_counter = match get(m, 7) {
185        Some(Value::Uint(n)) => u32::try_from(*n)
186            .map_err(|_| Error::Snapshot("outbound_group_counter exceeds u32 range".into()))?,
187        _ => 0,
188    };
189    // t8 (ICD registrations) is optional — absent in snapshots written before
190    // ICD support. Default to empty (no version bump).
191    let icd_clients = match get(m, 8) {
192        Some(arr) => {
193            let mut regs = Vec::new();
194            for rv in as_array(arr)? {
195                regs.push(icd_registration_from_value(rv)?);
196            }
197            regs
198        }
199        None => Vec::new(),
200    };
201
202    Ok(FabricEntry {
203        fabric_id: get_uint(m, 0)?,
204        ipk: byte_array::<16>(get_bytes(m, 1)?, "ipk")?,
205        rcac_cert: MatterCertificate::from_tlv(get_bytes(m, 2)?)?,
206        rcac_pkcs8: get_bytes(m, 3)?.to_vec(),
207        commissioner: commissioner_from_value(commissioner_val)?,
208        devices,
209        group_keys,
210        outbound_group_counter,
211        icd_clients,
212    })
213}
214
215fn icd_registration_from_value(v: &Value) -> Result<crate::icd::IcdRegistration, Error> {
216    let m = as_struct(v)?;
217    let start_counter = u32::try_from(get_uint(m, 4)?)
218        .map_err(|_| Error::Snapshot("icd start_counter exceeds u32 range".into()))?;
219    Ok(crate::icd::IcdRegistration::new(
220        get_uint(m, 0)?,
221        get_uint(m, 1)?,
222        get_uint(m, 2)?,
223        byte_array::<16>(get_bytes(m, 3)?, "icd key")?,
224        start_counter,
225    ))
226}
227
228fn group_key_from_value(v: &Value) -> Result<GroupKeySetConfig, Error> {
229    let m = as_struct(v)?;
230    let key_set_id = u16::try_from(get_uint(m, 0)?)
231        .map_err(|_| Error::Snapshot("key_set_id exceeds u16 range".into()))?;
232    let epoch_key = byte_array::<16>(get_bytes(m, 1)?, "epoch_key")?;
233    let epoch_start_time = get_uint(m, 2)?;
234    Ok(GroupKeySetConfig::new(
235        key_set_id,
236        epoch_key,
237        epoch_start_time,
238    ))
239}
240
241fn commissioner_from_value(v: &Value) -> Result<CommissionerIdentity, Error> {
242    let m = as_struct(v)?;
243    Ok(CommissionerIdentity {
244        node_id: get_uint(m, 0)?,
245        operational_pkcs8: get_bytes(m, 1)?.to_vec(),
246        noc: MatterCertificate::from_tlv(get_bytes(m, 2)?)?,
247    })
248}
249
250fn device_from_value(v: &Value) -> Result<DeviceEntry, Error> {
251    let m = as_struct(v)?;
252    let resumption_record = match get(m, 2) {
253        Some(Value::Bytes(b)) => Some(b.clone()),
254        _ => None,
255    };
256    let last_known_addr = match get(m, 3) {
257        Some(Value::Utf8(s)) => Some(s.clone()),
258        _ => None,
259    };
260    Ok(DeviceEntry {
261        node_id: get_uint(m, 0)?,
262        peer_noc_public_key: byte_array::<65>(get_bytes(m, 1)?, "peer_noc_public_key")?,
263        resumption_record,
264        last_known_addr,
265    })
266}
267
268// --- small TLV-Value accessors ---
269
270fn as_struct(v: &Value) -> Result<&[(Tag, Value)], Error> {
271    match v {
272        Value::Structure(members) => Ok(members),
273        _ => Err(Error::Snapshot("expected structure".into())),
274    }
275}
276
277fn as_array(v: &Value) -> Result<&[Value], Error> {
278    match v {
279        Value::Array(items) => Ok(items),
280        _ => Err(Error::Snapshot("expected array".into())),
281    }
282}
283
284fn get(members: &[(Tag, Value)], ctx: u8) -> Option<&Value> {
285    members
286        .iter()
287        .find(|(t, _)| *t == Tag::Context(ctx))
288        .map(|(_, v)| v)
289}
290
291fn get_uint(members: &[(Tag, Value)], ctx: u8) -> Result<u64, Error> {
292    match get(members, ctx) {
293        Some(Value::Uint(n)) => Ok(*n),
294        _ => Err(Error::Snapshot(format!(
295            "missing or non-uint at context {ctx}"
296        ))),
297    }
298}
299
300fn get_bytes(members: &[(Tag, Value)], ctx: u8) -> Result<&[u8], Error> {
301    match get(members, ctx) {
302        Some(Value::Bytes(b)) => Ok(b),
303        _ => Err(Error::Snapshot(format!(
304            "missing or non-bytes at context {ctx}"
305        ))),
306    }
307}
308
309fn byte_array<const N: usize>(b: &[u8], field: &str) -> Result<[u8; N], Error> {
310    b.try_into()
311        .map_err(|_| Error::Snapshot(format!("{field}: expected {N} bytes, got {}", b.len())))
312}
313
314#[cfg(test)]
315#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: CLAUDE.md allows unwrap/expect with justification.
316mod tests {
317    use super::*;
318    use crate::fabric::{create_fabric, FabricConfig};
319    use matter_cert::MatterTime;
320    use matter_commissioning::SystemNocRng;
321
322    fn sample_state() -> ControllerState {
323        let cfg = FabricConfig {
324            fabric_id: 0x1122_3344_5566_7788,
325            rcac_id: 1,
326            commissioner_node_id: 0x0000_0000_0000_0001,
327            validity: (
328                MatterTime::from_unix_secs(1_700_000_000),
329                MatterTime::NO_EXPIRY,
330            ),
331        };
332        let mut fabric = create_fabric(&cfg, &SystemNocRng).expect("create_fabric");
333        fabric.devices.push(DeviceEntry {
334            node_id: 0xABCD,
335            peer_noc_public_key: [0x04; 65],
336            resumption_record: Some(vec![1, 2, 3, 4]),
337            last_known_addr: Some("[fe80::1]:5540".to_string()),
338        });
339        fabric.devices.push(DeviceEntry {
340            node_id: 0xBEEF,
341            peer_noc_public_key: [0x04; 65],
342            resumption_record: None,
343            last_known_addr: None,
344        });
345        ControllerState {
346            fabrics: vec![fabric],
347        }
348    }
349
350    #[test]
351    fn round_trips_a_full_state() {
352        let state = sample_state();
353        let bytes = serialize(&state).expect("serialize");
354        let back = deserialize(&bytes).expect("deserialize");
355
356        assert_eq!(back.fabrics.len(), 1);
357        let (a, b) = (&state.fabrics[0], &back.fabrics[0]);
358        assert_eq!(a.fabric_id, b.fabric_id);
359        assert_eq!(a.ipk, b.ipk);
360        assert_eq!(a.rcac_pkcs8, b.rcac_pkcs8);
361        assert_eq!(a.rcac_cert.to_tlv().unwrap(), b.rcac_cert.to_tlv().unwrap());
362        assert_eq!(a.commissioner.node_id, b.commissioner.node_id);
363        assert_eq!(
364            a.commissioner.operational_pkcs8,
365            b.commissioner.operational_pkcs8
366        );
367        assert_eq!(
368            a.commissioner.noc.to_tlv().unwrap(),
369            b.commissioner.noc.to_tlv().unwrap()
370        );
371        assert_eq!(a.devices.len(), b.devices.len());
372        assert_eq!(a.devices[0].node_id, b.devices[0].node_id);
373        assert_eq!(
374            a.devices[0].resumption_record,
375            b.devices[0].resumption_record
376        );
377        assert_eq!(a.devices[0].last_known_addr, b.devices[0].last_known_addr);
378        assert_eq!(a.devices[1].resumption_record, None);
379        assert_eq!(a.devices[1].last_known_addr, None);
380    }
381
382    #[test]
383    fn empty_state_round_trips() {
384        let bytes = serialize(&ControllerState::default()).expect("serialize");
385        assert!(deserialize(&bytes).expect("deserialize").fabrics.is_empty());
386    }
387
388    #[test]
389    fn rejects_unknown_version() {
390        // Hand-build a root with version 99.
391        let root = Value::Structure(vec![
392            (Tag::Context(0), Value::Uint(99)),
393            (Tag::Context(1), Value::Array(vec![])),
394        ]);
395        let mut out = Vec::new();
396        let mut w = TlvWriter::new(&mut out);
397        w.write_value(Tag::Anonymous, &root).unwrap();
398        let err = deserialize(&out).expect_err("must reject");
399        assert!(matches!(err, Error::Snapshot(_)));
400    }
401
402    // --- property-based round-trip (CLAUDE.md: encoders get a proptest) ---
403
404    use proptest::prelude::*;
405    use std::sync::OnceLock;
406
407    /// Mint one real fabric and reuse it across all proptest cases — key
408    /// generation is expensive, but cloning a `FabricEntry` is cheap.
409    fn shared_fabric() -> &'static FabricEntry {
410        static FABRIC: OnceLock<FabricEntry> = OnceLock::new();
411        FABRIC.get_or_init(|| {
412            let cfg = FabricConfig {
413                fabric_id: 0x0102_0304_0506_0708,
414                rcac_id: 1,
415                commissioner_node_id: 0x0000_0000_0000_0001,
416                validity: (
417                    MatterTime::from_unix_secs(1_700_000_000),
418                    MatterTime::NO_EXPIRY,
419                ),
420            };
421            create_fabric(&cfg, &SystemNocRng).expect("mint shared fabric")
422        })
423    }
424
425    prop_compose! {
426        fn arb_device()(
427            node_id in any::<u64>(),
428            pk in prop::collection::vec(any::<u8>(), 65),
429            rr in prop::option::of(prop::collection::vec(any::<u8>(), 0..40)),
430            addr in prop::option::of("[ -~]{0,32}"),
431        ) -> DeviceEntry {
432            let mut peer_noc_public_key = [0u8; 65];
433            peer_noc_public_key.copy_from_slice(&pk);
434            DeviceEntry { node_id, peer_noc_public_key, resumption_record: rr, last_known_addr: addr }
435        }
436    }
437
438    proptest! {
439        /// `deserialize(serialize(state)) == state` for arbitrary device lists.
440        #[test]
441        fn snapshot_round_trips(devices in prop::collection::vec(arb_device(), 0..6)) {
442            let mut fabric = shared_fabric().clone();
443            fabric.devices = devices.clone();
444            let state = ControllerState { fabrics: vec![fabric] };
445
446            let bytes = serialize(&state).expect("serialize");
447            let back = deserialize(&bytes).expect("deserialize");
448
449            prop_assert_eq!(back.fabrics.len(), 1);
450            let dev_back = &back.fabrics[0].devices;
451            prop_assert_eq!(dev_back.len(), devices.len());
452            for (a, b) in devices.iter().zip(dev_back.iter()) {
453                prop_assert_eq!(a.node_id, b.node_id);
454                prop_assert_eq!(a.peer_noc_public_key, b.peer_noc_public_key);
455                prop_assert_eq!(&a.resumption_record, &b.resumption_record);
456                prop_assert_eq!(&a.last_known_addr, &b.last_known_addr);
457            }
458        }
459    }
460
461    // --- group-key persistence tests ---
462
463    #[test]
464    fn group_keys_round_trip() {
465        // A FabricEntry WITH group_keys and a non-zero counter must survive
466        // serialize → deserialize with all group fields preserved.
467        let mut fabric = shared_fabric().clone();
468        fabric.group_keys = vec![
469            GroupKeySetConfig::new(0x0001, [0xAA; 16], 1_700_000_000),
470            GroupKeySetConfig::new(0x0002, [0xBB; 16], 1_700_100_000),
471        ];
472        fabric.outbound_group_counter = 42;
473        let state = ControllerState {
474            fabrics: vec![fabric.clone()],
475        };
476
477        let bytes = serialize(&state).expect("serialize");
478        let back = deserialize(&bytes).expect("deserialize");
479
480        assert_eq!(back.fabrics.len(), 1);
481        let f = &back.fabrics[0];
482        assert_eq!(f.outbound_group_counter, 42);
483        assert_eq!(f.group_keys.len(), 2);
484        assert_eq!(f.group_keys[0].key_set_id, 0x0001);
485        assert_eq!(f.group_keys[0].epoch_key, [0xAA; 16]);
486        assert_eq!(f.group_keys[0].epoch_start_time, 1_700_000_000);
487        assert_eq!(f.group_keys[1].key_set_id, 0x0002);
488        assert_eq!(f.group_keys[1].epoch_key, [0xBB; 16]);
489        assert_eq!(f.group_keys[1].epoch_start_time, 1_700_100_000);
490    }
491
492    #[test]
493    fn icd_clients_round_trip() {
494        // A FabricEntry WITH ICD registrations must survive serialize →
495        // deserialize with all fields preserved (additive t8, no version bump).
496        let mut fabric = shared_fabric().clone();
497        fabric.icd_clients = vec![
498            crate::icd::IcdRegistration::new(0x0042, 1, 1, [0xCC; 16], 7),
499            crate::icd::IcdRegistration::new(0x0043, 1, 2, [0xDD; 16], 99),
500        ];
501        let state = ControllerState {
502            fabrics: vec![fabric.clone()],
503        };
504        let bytes = serialize(&state).expect("serialize");
505        let back = deserialize(&bytes).expect("deserialize");
506        assert_eq!(back.fabrics[0].icd_clients, fabric.icd_clients);
507    }
508
509    #[test]
510    fn old_snapshot_without_t6_t7_loads_with_defaults() {
511        // Simulate a v1 snapshot written by old code: a fabric value that has
512        // only t0..t5 (no t6/t7).  The deserializer must accept it and default
513        // group_keys to [] and outbound_group_counter to 0.
514        let fabric = shared_fabric().clone();
515
516        // Build the fabric Value manually with only t0..t5 (the old layout).
517        let old_fabric_val = Value::Structure(vec![
518            (Tag::Context(0), Value::Uint(fabric.fabric_id)),
519            (Tag::Context(1), Value::Bytes(fabric.ipk.to_vec())),
520            (
521                Tag::Context(2),
522                Value::Bytes(fabric.rcac_cert.to_tlv().expect("rcac tlv")),
523            ),
524            (Tag::Context(3), Value::Bytes(fabric.rcac_pkcs8.clone())),
525            (
526                Tag::Context(4),
527                commissioner_to_value(&fabric.commissioner).expect("commissioner"),
528            ),
529            (Tag::Context(5), Value::Array(vec![])), // no devices
530        ]);
531
532        let root = Value::Structure(vec![
533            (Tag::Context(0), Value::Uint(u64::from(SNAPSHOT_VERSION))),
534            (Tag::Context(1), Value::Array(vec![old_fabric_val])),
535        ]);
536
537        let mut out = Vec::new();
538        let mut w = TlvWriter::new(&mut out);
539        w.write_value(Tag::Anonymous, &root).unwrap();
540
541        let back = deserialize(&out).expect("old snapshot must load without error");
542        assert_eq!(back.fabrics.len(), 1);
543        let f = &back.fabrics[0];
544        assert!(
545            f.group_keys.is_empty(),
546            "group_keys must default to empty for old snapshot"
547        );
548        assert_eq!(
549            f.outbound_group_counter, 0,
550            "outbound_group_counter must default to 0 for old snapshot"
551        );
552    }
553}