• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Alan-Jowett / sonde / 23967847186

04 Apr 2026 12:58AM UTC coverage: 81.11% (-4.3%) from 85.446%
23967847186

push

github

web-flow
feat: remove legacy HMAC-SHA256 and ECDH/HKDF code (#629)

* docs: replace HMAC/ECDH/HKDF references with AES-256-GCM AEAD

Update all specification documents to reflect that AES-256-GCM (AEAD)
is now the sole frame authentication and encryption mechanism. This
aligns the docs with the code changes from PRs #627 (gateway/node AEAD)
and #628 (BLE pairing AEAD).

Changes across 18 files:
- Replace \HmacProvider\ trait references with \AeadProvider\
- Update frame format descriptions (HMAC tag → AEAD tag, 32B → 16B)
- Update codec function names (\ncode_frame\/\decode_frame\ → \ead_seal\/\ead_open\)
- Update error descriptions (HMAC verification → AEAD authentication failure)
- Update PEER_REQUEST/PEER_ACK verification to describe AES-256-GCM flow
- Update BLE pairing tool design to remove ECDH/HKDF sections (marked RETIRED)
- Update implementation guide dependencies (\hmac\ → \es-gcm\)
- Update IETF FAQ to describe AES-256-GCM instead of HMAC-SHA-256

Preserved:
- All RETIRED requirement markers (no IDs changed)
- \volve-495-phase1.md\ transition document (historical reference)
- Test function names that reference HMAC (code identifiers)
- T-0503c rejection test (validates old HMAC format is rejected)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: remove legacy HMAC-SHA256 and ECDH/HKDF code

All ESP-NOW frames and BLE pairing now use AES-256-GCM exclusively.
Removes the legacy HMAC-SHA256 frame codec, ECDH key exchange, HKDF
key derivation, and the aes-gcm-codec feature flag.

Protocol: deleted codec.rs, removed HmacProvider trait, ungated AEAD
Gateway: removed HMAC engine path, ECDH BLE handler, migrated tests
Node: removed HMAC wake cycle, peer request, BPF dispatch fallback
Pair: removed ECDH Phase 1/2, HMAC crypto, simplified store
E2E: removed HMAC harness, ungated AEAD tests
Specs: updated 18 docs replacing HMAC/ECDH with AEAD references

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sig... (continued)

662 of 839 new or added lines in 6 files covered. (78.9%)

248 existing lines in 14 files now uncovered.

19781 of 24388 relevant lines covered (81.11%)

147.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.05
/crates/sonde-node/src/program_store.rs
1
// SPDX-License-Identifier: MIT
2
// Copyright (c) 2026 sonde contributors
3

4
use crate::error::{NodeError, NodeResult};
5
use crate::traits::PlatformStorage;
6
use sonde_protocol::{MapDef, ProgramImage, Sha256Provider};
7

8
/// Contains raw BPF bytecode as stored in the program image. Map reference
9
/// relocation (LDDW `src=1` map indices) is **not** performed by `ProgramStore`;
10
/// each `BpfInterpreter` backend is responsible for handling unrelocated
11
/// references (either by pre-relocating in `load()` or at runtime).
12
#[derive(Debug, Clone)]
13
pub struct LoadedProgram {
14
    /// Raw BPF bytecode with LDDW `src=1` map references not yet relocated.
15
    pub bytecode: Vec<u8>,
16
    /// Map definitions from the program image.
17
    pub map_defs: Vec<MapDef>,
18
    /// Initial data for each map, parallel to `map_defs`.
19
    ///
20
    /// `map_initial_data[i]` carries the initial bytes for `map_defs[i]`.
21
    /// An empty `Vec<u8>` means the map has no initial data (zero-filled).
22
    pub map_initial_data: Vec<Vec<u8>>,
23
    /// SHA-256 hash of the CBOR program image.
24
    pub hash: Vec<u8>,
25
    /// Whether this is an ephemeral program (stored in RAM, run once).
26
    pub is_ephemeral: bool,
27
}
28

29
/// Manages A/B program partitions and program image lifecycle.
30
pub struct ProgramStore<'a, S: PlatformStorage> {
31
    storage: &'a mut S,
32
}
33

34
impl<'a, S: PlatformStorage> ProgramStore<'a, S> {
35
    pub fn new(storage: &'a mut S) -> Self {
41✔
36
        Self { storage }
41✔
37
    }
41✔
38

39
    /// Load the hash and raw bytes of the currently active resident program.
40
    ///
41
    /// Returns `(hash, raw_bytes)`.  The caller is responsible for decoding
42
    /// the CBOR image only when BPF execution is needed — this avoids
43
    /// unnecessary CPU/heap work in cycles that return early (Reboot,
44
    /// transport failures, transfer failures).
45
    ///
46
    /// Returns an empty hash and `None` bytes when no program is installed
47
    /// or when the active partition index is invalid (> 1).
48
    pub fn load_active_raw(&self, sha: &dyn Sha256Provider) -> (Vec<u8>, Option<Vec<u8>>) {
31✔
49
        let (_interval, active_partition) = self.storage.read_schedule();
31✔
50
        if active_partition > 1 {
31✔
51
            return (Vec::new(), None);
1✔
52
        }
30✔
53
        match self.storage.read_program(active_partition) {
30✔
54
            Some(image_bytes) => {
2✔
55
                let hash = sha.hash(&image_bytes).to_vec();
2✔
56
                (hash, Some(image_bytes))
2✔
57
            }
58
            None => (Vec::new(), None),
28✔
59
        }
60
    }
31✔
61

62
    /// Decode raw CBOR image bytes into a [`LoadedProgram`] for a **resident**
63
    /// program (sets `is_ephemeral: false`).
64
    ///
65
    /// Called in step 9 of the wake cycle when BPF execution is needed.
66
    /// Separated from [`load_active_raw`](Self::load_active_raw) so that
67
    /// decode is deferred until we know the program will actually execute.
68
    ///
69
    /// Returns `None` if CBOR decoding fails.
70
    pub(crate) fn decode_image(image_bytes: &[u8], hash: Vec<u8>) -> Option<LoadedProgram> {
2✔
71
        ProgramImage::decode(image_bytes)
2✔
72
            .ok()
2✔
73
            .map(|image| LoadedProgram {
2✔
74
                bytecode: image.bytecode,
1✔
75
                map_defs: image.maps,
1✔
76
                map_initial_data: image.map_initial_data,
1✔
77
                hash,
1✔
78
                is_ephemeral: false,
79
            })
1✔
80
    }
2✔
81

82
    /// Install a new resident program via chunked transfer.
83
    ///
84
    /// 1. Verify the SHA-256 hash against `expected_hash`.
85
    /// 2. Decode the CBOR program image.
86
    /// 3. Validate that map definitions fit within `map_budget`.
87
    /// 4. Write to the **inactive** partition.
88
    /// 5. Flip the active partition flag.
89
    ///
90
    /// Map budget is validated *before* the A/B swap so the old program
91
    /// remains active if the new program's maps don't fit.
92
    ///
93
    /// Returns the decoded program on success. On failure, the existing
94
    /// active program is untouched (A/B atomicity).
95
    pub fn install_resident(
7✔
96
        &mut self,
7✔
97
        image_bytes: &[u8],
7✔
98
        expected_hash: &[u8],
7✔
99
        sha: &(impl Sha256Provider + ?Sized),
7✔
100
        map_budget: usize,
7✔
101
    ) -> NodeResult<LoadedProgram> {
7✔
102
        // Verify hash
103
        let actual_hash = sha.hash(image_bytes);
7✔
104
        if actual_hash.as_slice() != expected_hash {
7✔
UNCOV
105
            return Err(NodeError::ProgramHashMismatch);
×
106
        }
7✔
107

108
        // Decode the CBOR program image
109
        let image = ProgramImage::decode(image_bytes)
7✔
110
            .map_err(|_| NodeError::ProgramDecodeFailed("program image decode failed"))?;
7✔
111

112
        // Validate map definitions (type, key_size, overflow) and budget
113
        // before committing the A/B swap so a bad program never becomes active.
114
        crate::map_storage::MapStorage::validate_map_defs(&image.maps)?;
7✔
115
        let required = crate::map_storage::MapStorage::required_bytes(&image.maps);
7✔
116
        if required > map_budget {
7✔
UNCOV
117
            return Err(NodeError::MapBudgetExceeded {
×
UNCOV
118
                required,
×
UNCOV
119
                available: map_budget,
×
UNCOV
120
            });
×
121
        }
7✔
122

123
        // Write to the inactive partition
124
        let (_interval, active_partition) = self.storage.read_schedule();
7✔
125
        if active_partition > 1 {
7✔
126
            return Err(NodeError::StorageError("invalid active partition index"));
1✔
127
        }
6✔
128
        let inactive_partition = 1 - active_partition;
6✔
129
        self.storage
6✔
130
            .write_program(inactive_partition, image_bytes)?;
6✔
131

132
        // Re-read the written program and verify its hash to detect
133
        // flash write corruption or partial writes before committing
134
        // the A/B swap.
135
        let written_bytes = self
6✔
136
            .storage
6✔
137
            .read_program(inactive_partition)
6✔
138
            .ok_or(NodeError::StorageError("failed to re-read written program"))?;
6✔
139
        let written_hash = sha.hash(&written_bytes);
6✔
140
        if written_hash.as_slice() != expected_hash {
6✔
141
            return Err(NodeError::ProgramHashMismatch);
×
142
        }
6✔
143

144
        // Flip active partition
145
        self.storage.write_active_partition(inactive_partition)?;
6✔
146

147
        Ok(LoadedProgram {
6✔
148
            bytecode: image.bytecode,
6✔
149
            map_defs: image.maps,
6✔
150
            map_initial_data: image.map_initial_data,
6✔
151
            hash: actual_hash.to_vec(),
6✔
152
            is_ephemeral: false,
6✔
153
        })
6✔
154
    }
7✔
155

156
    /// Load an ephemeral program (stored in RAM, not flash).
157
    ///
158
    /// Verifies the hash, decodes the image, and validates map definitions
159
    /// (type, key_size, budget). Does not write to flash or change the
160
    /// active partition. Validation happens before the caller sends
161
    /// `PROGRAM_ACK`, so an unrunnable ephemeral program is never ACK'd.
162
    pub fn load_ephemeral(
3✔
163
        &self,
3✔
164
        image_bytes: &[u8],
3✔
165
        expected_hash: &[u8],
3✔
166
        sha: &(impl Sha256Provider + ?Sized),
3✔
167
    ) -> NodeResult<LoadedProgram> {
3✔
168
        let actual_hash = sha.hash(image_bytes);
3✔
169
        if actual_hash.as_slice() != expected_hash {
3✔
170
            return Err(NodeError::ProgramHashMismatch);
1✔
171
        }
2✔
172

173
        let image = ProgramImage::decode(image_bytes)
2✔
174
            .map_err(|_| NodeError::ProgramDecodeFailed("program image decode failed"))?;
2✔
175

176
        // Validate map definitions before returning Ok, so the caller
177
        // won't send PROGRAM_ACK for an unrunnable program.
178
        //
179
        // Ephemeral programs must not declare maps (ND-0503: "resident
180
        // program is unaffected by ephemeral execution"). Re-allocating
181
        // maps would destroy the resident program's sleep-persistent state.
182
        if !image.maps.is_empty() {
2✔
183
            return Err(NodeError::ProgramDecodeFailed(
×
184
                "ephemeral programs must not declare maps",
×
185
            ));
×
186
        }
2✔
187

188
        Ok(LoadedProgram {
2✔
189
            bytecode: image.bytecode,
2✔
190
            map_defs: image.maps,
2✔
191
            map_initial_data: image.map_initial_data,
2✔
192
            hash: actual_hash.to_vec(),
2✔
193
            is_ephemeral: true,
2✔
194
        })
2✔
195
    }
3✔
196
}
197

198
// NOTE: `resolve_map_references` was removed in the sonde-bpf migration.
199
// LDDW `src=1` map reference relocation is now handled at runtime by the
200
// `sonde_bpf` interpreter backend.
201

202
#[cfg(test)]
203
mod tests {
204
    use super::*;
205
    use crate::error::NodeError;
206

207
    struct TestSha256;
208
    impl Sha256Provider for TestSha256 {
209
        fn hash(&self, data: &[u8]) -> [u8; 32] {
12✔
210
            use sha2::Digest;
211
            let mut hasher = sha2::Sha256::new();
12✔
212
            hasher.update(data);
12✔
213
            hasher.finalize().into()
12✔
214
        }
12✔
215
    }
216

217
    /// Local mock storage for program_store tests.
218
    struct MockStorage {
219
        schedule_interval: u32,
220
        active_partition: u8,
221
        programs: [Option<Vec<u8>>; 2],
222
    }
223

224
    impl MockStorage {
225
        fn new() -> Self {
7✔
226
            Self {
7✔
227
                schedule_interval: 60,
7✔
228
                active_partition: 0,
7✔
229
                programs: [None, None],
7✔
230
            }
7✔
231
        }
7✔
232
    }
233

234
    impl PlatformStorage for MockStorage {
235
        fn read_key(&self) -> Option<(u16, [u8; 32])> {
×
236
            None
×
237
        }
×
238
        fn write_key(&mut self, _kh: u16, _psk: &[u8; 32]) -> NodeResult<()> {
×
239
            Ok(())
×
240
        }
×
241
        fn erase_key(&mut self) -> NodeResult<()> {
×
242
            Ok(())
×
243
        }
×
244
        fn read_schedule(&self) -> (u32, u8) {
6✔
245
            (self.schedule_interval, self.active_partition)
6✔
246
        }
6✔
247
        fn write_schedule_interval(&mut self, interval_s: u32) -> NodeResult<()> {
×
248
            self.schedule_interval = interval_s;
×
249
            Ok(())
×
250
        }
×
251
        fn write_active_partition(&mut self, partition: u8) -> NodeResult<()> {
1✔
252
            self.active_partition = partition;
1✔
253
            Ok(())
1✔
254
        }
1✔
255
        fn reset_schedule(&mut self) -> NodeResult<()> {
×
256
            self.schedule_interval = 60;
×
257
            self.active_partition = 0;
×
258
            Ok(())
×
259
        }
×
260
        fn read_program(&self, partition: u8) -> Option<Vec<u8>> {
4✔
261
            self.programs[partition as usize].clone()
4✔
262
        }
4✔
263
        fn write_program(&mut self, partition: u8, image: &[u8]) -> NodeResult<()> {
1✔
264
            self.programs[partition as usize] = Some(image.to_vec());
1✔
265
            Ok(())
1✔
266
        }
1✔
267
        fn erase_program(&mut self, partition: u8) -> NodeResult<()> {
×
268
            self.programs[partition as usize] = None;
×
269
            Ok(())
×
270
        }
×
271
        fn take_early_wake_flag(&mut self) -> bool {
×
272
            false
×
273
        }
×
274
        fn set_early_wake_flag(&mut self) -> NodeResult<()> {
×
275
            Ok(())
×
276
        }
×
277
    }
278

279
    fn make_test_image(bytecode: &[u8], maps: &[MapDef]) -> (Vec<u8>, Vec<u8>) {
6✔
280
        let image = ProgramImage {
6✔
281
            bytecode: bytecode.to_vec(),
6✔
282
            maps: maps.to_vec(),
6✔
283
            map_initial_data: vec![Vec::new(); maps.len()],
6✔
284
        };
6✔
285
        let cbor = image.encode_deterministic().unwrap();
6✔
286
        let hash = TestSha256.hash(&cbor).to_vec();
6✔
287
        (cbor, hash)
6✔
288
    }
6✔
289

290
    #[test]
291
    fn test_load_ephemeral_valid() {
1✔
292
        let (cbor, hash) = make_test_image(&[0x95, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], &[]);
1✔
293
        let mut storage = MockStorage::new();
1✔
294
        let store = ProgramStore::new(&mut storage);
1✔
295
        let loaded = store.load_ephemeral(&cbor, &hash, &TestSha256).unwrap();
1✔
296
        assert!(loaded.is_ephemeral);
1✔
297
        assert_eq!(loaded.hash, hash);
1✔
298
    }
1✔
299

300
    #[test]
301
    fn test_load_ephemeral_hash_mismatch() {
1✔
302
        let (cbor, _hash) = make_test_image(&[0x95, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], &[]);
1✔
303
        let wrong_hash = vec![0xFF; 32];
1✔
304
        let mut storage = MockStorage::new();
1✔
305
        let store = ProgramStore::new(&mut storage);
1✔
306
        let result = store.load_ephemeral(&cbor, &wrong_hash, &TestSha256);
1✔
307
        assert!(matches!(result, Err(NodeError::ProgramHashMismatch)));
1✔
308
    }
1✔
309

310
    #[test]
311
    fn test_install_resident_ab_swap() {
1✔
312
        let (cbor, hash) = make_test_image(&[0x95, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], &[]);
1✔
313
        let mut storage = MockStorage::new();
1✔
314
        // Active partition is 0, so install should write to partition 1
315
        {
316
            let mut store = ProgramStore::new(&mut storage);
1✔
317
            let loaded = store
1✔
318
                .install_resident(&cbor, &hash, &TestSha256, 4096)
1✔
319
                .unwrap();
1✔
320
            assert!(!loaded.is_ephemeral);
1✔
321
        }
322
        // Active partition should now be 1
323
        assert_eq!(storage.read_schedule().1, 1);
1✔
324
        assert!(storage.read_program(1).is_some());
1✔
325
    }
1✔
326

327
    #[test]
328
    fn test_install_resident_invalid_active_partition() {
1✔
329
        let (cbor, hash) = make_test_image(&[0x95, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], &[]);
1✔
330
        let mut storage = MockStorage::new();
1✔
331
        storage.active_partition = 5; // invalid
1✔
332
        let mut store = ProgramStore::new(&mut storage);
1✔
333
        let result = store.install_resident(&cbor, &hash, &TestSha256, 4096);
1✔
334
        assert!(matches!(result, Err(NodeError::StorageError(_))));
1✔
335
    }
1✔
336

337
    // ---- load_active_raw tests ----
338

339
    #[test]
340
    fn test_load_active_raw_no_program() {
1✔
341
        let mut storage = MockStorage::new();
1✔
342
        let store = ProgramStore::new(&mut storage);
1✔
343
        let (hash, bytes) = store.load_active_raw(&TestSha256);
1✔
344
        assert!(hash.is_empty());
1✔
345
        assert!(bytes.is_none());
1✔
346
    }
1✔
347

348
    #[test]
349
    fn test_load_active_raw_invalid_partition() {
1✔
350
        let mut storage = MockStorage::new();
1✔
351
        storage.active_partition = 5;
1✔
352
        let store = ProgramStore::new(&mut storage);
1✔
353
        let (hash, bytes) = store.load_active_raw(&TestSha256);
1✔
354
        assert!(hash.is_empty());
1✔
355
        assert!(bytes.is_none());
1✔
356
    }
1✔
357

358
    #[test]
359
    fn test_load_active_raw_valid_program() {
1✔
360
        let (cbor, expected_hash) =
1✔
361
            make_test_image(&[0x95, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], &[]);
1✔
362
        let mut storage = MockStorage::new();
1✔
363
        storage.programs[0] = Some(cbor.clone());
1✔
364
        let store = ProgramStore::new(&mut storage);
1✔
365
        let (hash, bytes) = store.load_active_raw(&TestSha256);
1✔
366
        assert_eq!(hash, expected_hash);
1✔
367
        assert_eq!(bytes.unwrap(), cbor);
1✔
368
    }
1✔
369

370
    // ---- decode_image tests ----
371

372
    #[test]
373
    fn test_decode_image_valid() {
1✔
374
        let (cbor, hash) = make_test_image(&[0x95, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], &[]);
1✔
375
        let loaded = ProgramStore::<MockStorage>::decode_image(&cbor, hash.clone()).unwrap();
1✔
376
        assert_eq!(loaded.hash, hash);
1✔
377
        assert!(!loaded.is_ephemeral);
1✔
378
        assert_eq!(
1✔
379
            loaded.bytecode,
380
            vec![0x95, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
1✔
381
        );
382
    }
1✔
383

384
    #[test]
385
    fn test_decode_image_invalid_cbor() {
1✔
386
        let bad_bytes = vec![0xFF, 0xFE, 0xFD];
1✔
387
        let hash = vec![0x42; 32];
1✔
388
        let result = ProgramStore::<MockStorage>::decode_image(&bad_bytes, hash);
1✔
389
        assert!(result.is_none());
1✔
390
    }
1✔
391
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc