-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Expand file tree
/
Copy pathEncryptedStorage.cpp
More file actions
1807 lines (1598 loc) · 65.1 KB
/
Copy pathEncryptedStorage.cpp
File metadata and controls
1807 lines (1598 loc) · 65.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#include "configuration.h"
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
// Common includes - available for all platform implementations
#include "EncryptedStorage.h"
#include "FSCommon.h"
#include "SPILock.h"
#include "SafeFile.h"
#include "SecureZero.h"
#include "gps/RTC.h"
#include <algorithm>
#ifdef ARCH_NRF52
// nRF52 CC310 hardware crypto
#include <Adafruit_nRFCrypto.h>
#include <nrf.h>
extern "C" {
#include "nrf_cc310/include/crys_hmac.h"
#include "nrf_cc310/include/ssi_aes.h"
#include "nrf_cc310/include/ssi_aes_defs.h"
}
namespace EncryptedStorage
{
// ---------------------------------------------------------------------------
// File paths and domain-separation strings
// ---------------------------------------------------------------------------
static const char *DEK_FILENAME = "/prefs/.dek";
static const char *TOKEN_FILENAME = "/prefs/.unlock_token";
static const char *BACKOFF_FILENAME = "/prefs/.backoff";
// Passphrase-mixed KEK
static const char *KEK_DOMAIN = "meshtastic-tak-kek-v2";
// Ephemeral: FICR-only, used only to wrap DEK inside the unlock token
static const char *EPHEMERAL_KEK_DOMAIN = "meshtastic-tak-ephemeral-v1";
// HMAC auth label for DEK file
static const char *DEK_AUTH_LABEL = "mdek-auth";
// ---------------------------------------------------------------------------
// Module-level key state
// ---------------------------------------------------------------------------
// Passphrase-mixed KEK (v2), derived on provision/unlock
static uint8_t kek[AES_KEY_SIZE];
static bool kekDerived = false;
// FICR-only ephemeral KEK for token wrapping/unwrapping
static uint8_t ephemeralKek[AES_KEY_SIZE];
static bool ephemeralKekDerived = false;
// Data Encryption Key - loaded from /prefs/.dek, never stored in plaintext
static uint8_t dek[AES_KEY_SIZE];
static bool dekLoaded = false;
// Reason the device is currently locked (set in initLocked / readAndConsumeToken)
static const char *lockReason = "not_provisioned";
// Token state after successful unlock - exposed via getBootsRemaining() / getValidUntilEpoch()
static uint8_t s_bootsRemaining = 0;
static uint32_t s_validUntilEpoch = 0;
// Uptime-based session limit. Set by setSession() at successful unlock.
// s_sessionMaxMs = 0 means no limit (token-only enforcement). RAM-only:
// reboot clears these, but readAndConsumeToken() persists the
// sessionMaxSeconds in the token file and re-calls setSession() from
// the token-load path so token-auto-unlocked sessions inherit the
// same cap. consumeSessionBoot() re-arms in place between sessions.
static uint32_t s_sessionMaxMs = 0;
static uint32_t s_sessionStartedMs = 0;
// Backoff state - seconds remaining before next passphrase attempt is allowed
static uint32_t s_backoffSecondsRemaining = 0;
// ---------------------------------------------------------------------------
// Passphrase attempt backoff helpers
// ---------------------------------------------------------------------------
// Returns the delay in seconds for a given number of failed attempts.
// Schedule: 5, 10, 20, 40, 80, 160, 320, 900 (capped)
static uint32_t backoffDelay(uint8_t attempts)
{
if (attempts == 0)
return 0;
uint32_t delay = 5u;
for (uint8_t i = 1; i < attempts; i++) {
delay *= 2;
if (delay >= 900)
return 900;
}
return delay;
}
// RAM-only: millis() at the most recent failed attempt this boot. Lets us
// enforce within-boot backoff without relying on RTC. Reset to 0 each boot.
static uint32_t s_lastFailMillis = 0;
// Forward declarations for the crypto helpers defined further down. The
// backoff section (next) needs them and we want the backoff state next to
// the rest of the boot/state machinery rather than buried after the crypto.
static bool computeHMAC(const uint8_t *key, size_t keyLen, const uint8_t *data, size_t dataLen, uint8_t *hmacOut);
static bool constTimeEq(const uint8_t *a, const uint8_t *b, size_t len);
// HMAC domain label for the backoff file. Distinct from DEK_AUTH_LABEL so a
// cross-file replay (use a DEK-MAC as a backoff-MAC or vice versa) fails.
static const char *BACKOFF_AUTH_LABEL = "backoff-auth";
// Backoff state file format (38 bytes): attempts(1) + bootsSinceFail(1) +
// lastFailEpoch(4) + HMAC-SHA256(ephemeralKEK, BACKOFF_AUTH_LABEL || body)(32)
//
// H4 (audit): MAC the file with the FICR-derived ephemeralKek so an attacker
// who can write LittleFS (DFU file inject, compromised firmware) cannot
// forge a low-attempts file to bypass backoff. Atomic write via SafeFile
// closes the power-glitch-during-write window. Missing/short/MAC-fail are
// all treated as max-attempts (kBackoffMaxAttempts) so a tamper-delete can
// only INCREASE the wait, never decrease it.
//
// bootsSinceFail is incremented once per boot in initLocked() (saturating at
// 255). It provides a reliable monotonic across-reboot counter for backoff
// enforcement even when the RTC is unset, closing the reboot-bypass: an
// attacker who reboots between failed attempts cannot fast-forward through
// the backoff window because each reboot costs ~3-5 s of nRF52 boot time
// and only advances bootsSinceFail by 1.
static constexpr size_t BACKOFF_BODY_SIZE = 6;
static constexpr size_t BACKOFF_SIZE = BACKOFF_BODY_SIZE + HMAC_SIZE; // 38 bytes
static constexpr uint8_t kBackoffMaxAttempts = 255;
// Compute HMAC-SHA256(ephemeralKek, "backoff-auth" || body) into `out`.
// Caller must already hold CC310 (nRFCrypto.begin/end).
static bool computeBackoffHmac(const uint8_t body[BACKOFF_BODY_SIZE], uint8_t out[HMAC_SIZE])
{
if (!ephemeralKekDerived)
return false;
size_t labelLen = strlen(BACKOFF_AUTH_LABEL);
meshtastic_security::ZeroizingBuffer<32 + BACKOFF_BODY_SIZE> input; // labelLen <= 32
memcpy(input.data(), BACKOFF_AUTH_LABEL, labelLen);
memcpy(input.data() + labelLen, body, BACKOFF_BODY_SIZE);
return computeHMAC(ephemeralKek, AES_KEY_SIZE, input.data(), labelLen + BACKOFF_BODY_SIZE, out);
}
static void readBackoff(uint8_t &attempts, uint8_t &bootsSinceFail, uint32_t &lastFailEpoch)
{
// Default outputs: zero-attempts. Reassigned to "max" below if the file
// is missing OR present-but-tampered. The fresh-device (pre-provision)
// case is handled by bumpBootsSinceFailOnBoot's early-return; once
// provision has run, the file is always present and a missing file
// means something hostile deleted it.
attempts = 0;
bootsSinceFail = 0;
lastFailEpoch = 0;
#ifdef FSCom
meshtastic_security::ZeroizingBuffer<BACKOFF_SIZE> buf;
{
concurrency::LockGuard g(spiLock);
auto f = FSCom.open(BACKOFF_FILENAME, FILE_O_READ);
if (!f) {
// Fresh device (no provision yet) OR an attacker deleted the
// file. Caller resolves the ambiguity via isProvisioned() -
// see bumpBootsSinceFailOnBoot and the unlock backoff gate.
return;
}
size_t sz = f.size();
if (sz != BACKOFF_SIZE) {
f.close();
attempts = kBackoffMaxAttempts;
return;
}
size_t n = f.read(buf.data(), BACKOFF_SIZE);
f.close();
if (n != BACKOFF_SIZE) {
attempts = kBackoffMaxAttempts;
return;
}
}
// Verify HMAC under lock-free CC310 access (we hold no spiLock here).
uint8_t expected[HMAC_SIZE];
nRFCrypto.begin();
bool ok = computeBackoffHmac(buf.data(), expected);
nRFCrypto.end();
if (!ok || !constTimeEq(expected, buf.data() + BACKOFF_BODY_SIZE, HMAC_SIZE)) {
// Tampered or attacker-rewritten file. Fail closed.
attempts = kBackoffMaxAttempts;
return;
}
attempts = buf.data()[0];
bootsSinceFail = buf.data()[1];
memcpy(&lastFailEpoch, buf.data() + 2, 4);
#endif
}
static void writeBackoff(uint8_t attempts, uint8_t bootsSinceFail, uint32_t lastFailEpoch)
{
#ifdef FSCom
meshtastic_security::ZeroizingBuffer<BACKOFF_SIZE> buf;
buf.data()[0] = attempts;
buf.data()[1] = bootsSinceFail;
memcpy(buf.data() + 2, &lastFailEpoch, 4);
uint8_t mac[HMAC_SIZE];
nRFCrypto.begin();
bool ok = computeBackoffHmac(buf.data(), mac);
nRFCrypto.end();
if (!ok) {
LOG_ERROR("EncryptedStorage: backoff HMAC failed");
return;
}
memcpy(buf.data() + BACKOFF_BODY_SIZE, mac, HMAC_SIZE);
SafeFile sf(BACKOFF_FILENAME, /*fullAtomic=*/true);
sf.write(buf.data(), BACKOFF_SIZE);
if (!sf.close()) {
LOG_ERROR("EncryptedStorage: backoff atomic write failed");
}
#endif
}
// Called once per boot from initLocked(). Skip the bump on a fresh
// (un-provisioned) device - there's no backoff file to MAC against yet and
// readBackoff would return kBackoffMaxAttempts which would be wrong here.
static void bumpBootsSinceFailOnBoot()
{
if (!isProvisioned())
return;
uint8_t attempts;
uint8_t bootsSinceFail;
uint32_t lastFailEpoch;
readBackoff(attempts, bootsSinceFail, lastFailEpoch);
if (attempts == 0 || attempts == kBackoffMaxAttempts)
return;
if (bootsSinceFail < 255)
bootsSinceFail++;
writeBackoff(attempts, bootsSinceFail, lastFailEpoch);
}
// On successful unlock, write a freshly-MAC'd attempts=0 sentinel so the
// file always exists post-provision. Missing == hostile delete from there
// on. (Removing the file instead would make "missing == fresh-cleared" and
// re-open the delete-to-reset bypass that H4 exists to close.)
static void clearBackoff()
{
writeBackoff(0, 0, 0);
s_backoffSecondsRemaining = 0;
s_lastFailMillis = 0;
}
// ---------------------------------------------------------------------------
// Internal helpers: FICR data extraction
// ---------------------------------------------------------------------------
static void readFICR(uint8_t efuseData[16])
{
// Copy FICR registers to local vars before memcpy (registers are volatile)
uint32_t tmp;
tmp = NRF_FICR->DEVICEID[0];
memcpy(efuseData, &tmp, 4);
tmp = NRF_FICR->DEVICEID[1];
memcpy(efuseData + 4, &tmp, 4);
tmp = NRF_FICR->DEVICEADDR[0];
memcpy(efuseData + 8, &tmp, 4);
tmp = NRF_FICR->DEVICEADDR[1];
memcpy(efuseData + 12, &tmp, 4);
}
// ---------------------------------------------------------------------------
// Internal helpers: CC310 crypto primitives
// ---------------------------------------------------------------------------
/// AES-128-CTR encrypt/decrypt (symmetric). Caller holds CC310.
static bool aesCtr128(const uint8_t *key, const uint8_t *nonce, size_t nonceLen, const uint8_t *input, size_t inputLen,
uint8_t *output)
{
if (inputLen == 0)
return true;
SaSiAesUserContext_t ctx;
SaSiAesUserKeyData_t keyData;
SaSiAesIv_t iv;
memset(iv, 0, sizeof(iv));
size_t copyLen = (nonceLen < sizeof(iv)) ? nonceLen : sizeof(iv);
memcpy(iv, nonce, copyLen);
SaSiError_t err = SaSi_AesInit(&ctx, SASI_AES_ENCRYPT, SASI_AES_MODE_CTR, SASI_AES_PADDING_NONE);
if (err != 0) {
LOG_ERROR("EncryptedStorage: AES init failed: 0x%x", err);
return false;
}
keyData.pKey = (uint8_t *)key;
keyData.keySize = AES_KEY_SIZE;
err = SaSi_AesSetKey(&ctx, SASI_AES_USER_KEY, &keyData, sizeof(keyData));
if (err != 0) {
LOG_ERROR("EncryptedStorage: AES setkey failed: 0x%x", err);
SaSi_AesFree(&ctx);
return false;
}
err = SaSi_AesSetIv(&ctx, iv);
if (err != 0) {
LOG_ERROR("EncryptedStorage: AES setiv failed: 0x%x", err);
SaSi_AesFree(&ctx);
return false;
}
size_t processed = 0;
size_t fullBlocks = (inputLen / AES_BLOCK_SIZE) * AES_BLOCK_SIZE;
if (fullBlocks > 0) {
err = SaSi_AesBlock(&ctx, (uint8_t *)input, fullBlocks, output);
if (err != 0) {
LOG_ERROR("EncryptedStorage: AES block failed: 0x%x", err);
SaSi_AesFree(&ctx);
return false;
}
processed = fullBlocks;
}
size_t remaining = inputLen - processed;
size_t finishOutSize = remaining;
err = SaSi_AesFinish(&ctx, remaining, (uint8_t *)input + processed, remaining, output + processed, &finishOutSize);
if (err != 0) {
LOG_ERROR("EncryptedStorage: AES finish failed: 0x%x", err);
SaSi_AesFree(&ctx);
return false;
}
SaSi_AesFree(&ctx);
return true;
}
/// Compute HMAC-SHA256(key, data). Caller holds CC310.
static bool computeHMAC(const uint8_t *key, size_t keyLen, const uint8_t *data, size_t dataLen, uint8_t *hmacOut)
{
CRYS_HASH_Result_t hmacResult;
CRYSError_t err = CRYS_HMAC(CRYS_HASH_SHA256_mode, (uint8_t *)key, (uint16_t)keyLen, (uint8_t *)data, dataLen, hmacResult);
if (err != 0) {
LOG_ERROR("EncryptedStorage: CRYS_HMAC failed: 0x%x", err);
return false;
}
memcpy(hmacOut, hmacResult, HMAC_SIZE);
return true;
}
/// Constant-time memory comparison (avoids timing side-channels on HMAC compare).
static bool constTimeEq(const uint8_t *a, const uint8_t *b, size_t len)
{
uint8_t diff = 0;
for (size_t i = 0; i < len; i++)
diff |= a[i] ^ b[i];
return diff == 0;
}
// ---------------------------------------------------------------------------
// Internal helpers: KEK derivation
// ---------------------------------------------------------------------------
/**
* Derive the passphrase-mixed KEK and store in module-level kek[].
* SHA-256("device-efuse-data" || FICR_16 || passphrase || KEK_DOMAIN) → first 16 bytes.
* Caller must hold CC310 (nRFCrypto.begin()).
*/
static bool deriveKEK(const uint8_t *passphrase, size_t passphraseLen)
{
uint8_t efuseData[16];
readFICR(efuseData);
static const char *prefix = "device-efuse-data";
uint8_t sha256Result[32];
nRFCrypto_Hash hash;
if (!hash.begin(CRYS_HASH_SHA256_mode)) {
LOG_ERROR("EncryptedStorage: SHA-256 init failed (KEK)");
meshtastic_security::secure_zero(efuseData, sizeof(efuseData));
return false;
}
hash.update((uint8_t *)prefix, strlen(prefix));
hash.update(efuseData, sizeof(efuseData));
hash.update((uint8_t *)passphrase, passphraseLen);
hash.update((uint8_t *)KEK_DOMAIN, strlen(KEK_DOMAIN));
hash.end(sha256Result);
memcpy(kek, sha256Result, AES_KEY_SIZE);
meshtastic_security::secure_zero(sha256Result, sizeof(sha256Result));
meshtastic_security::secure_zero(efuseData, sizeof(efuseData));
kekDerived = true;
return true;
}
/**
* Derive the ephemeral KEK (FICR-only, separate domain) into ephemeralKek[].
* Used only for wrapping/unwrapping the unlock token.
* Caller must hold CC310.
*/
static bool deriveEphemeralKEK()
{
if (ephemeralKekDerived)
return true;
uint8_t efuseData[16];
readFICR(efuseData);
static const char *prefix = "device-efuse-data";
uint8_t sha256Result[32];
nRFCrypto_Hash hash;
if (!hash.begin(CRYS_HASH_SHA256_mode)) {
LOG_ERROR("EncryptedStorage: SHA-256 init failed (ephemeral KEK)");
meshtastic_security::secure_zero(efuseData, sizeof(efuseData));
return false;
}
hash.update((uint8_t *)prefix, strlen(prefix));
hash.update(efuseData, sizeof(efuseData));
hash.update((uint8_t *)EPHEMERAL_KEK_DOMAIN, strlen(EPHEMERAL_KEK_DOMAIN));
hash.end(sha256Result);
memcpy(ephemeralKek, sha256Result, AES_KEY_SIZE);
meshtastic_security::secure_zero(sha256Result, sizeof(sha256Result));
meshtastic_security::secure_zero(efuseData, sizeof(efuseData));
ephemeralKekDerived = true;
return true;
}
// ---------------------------------------------------------------------------
// Internal helpers: DEK file I/O
// ---------------------------------------------------------------------------
/**
* Load DEK from the DEK file using the current kek[].
* Verifies HMAC before returning the DEK.
*/
static bool loadDEK()
{
#ifdef FSCom
concurrency::LockGuard g(spiLock);
auto f = FSCom.open(DEK_FILENAME, FILE_O_READ);
if (!f)
return false;
size_t fileSize = f.size();
if (fileSize != DEK_SIZE) {
f.close();
return false;
}
uint8_t buf[DEK_SIZE];
size_t bytesRead = f.read(buf, sizeof(buf));
f.close();
if (bytesRead != DEK_SIZE) {
LOG_ERROR("EncryptedStorage: DEK short read");
return false;
}
// Check magic
uint32_t magic;
memcpy(&magic, buf, 4);
if (magic != DEK_MAGIC) {
LOG_ERROR("EncryptedStorage: DEK bad magic");
return false;
}
uint8_t *nonce = buf + 4;
uint8_t *encDek = buf + 4 + NONCE_SIZE;
// Verify HMAC-SHA256(KEK, DEK_AUTH_LABEL || nonce || encDEK)
size_t authLabelLen = strlen(DEK_AUTH_LABEL);
size_t hmacInputLen = authLabelLen + NONCE_SIZE + AES_KEY_SIZE;
auto hmacInput = meshtastic_security::make_zeroizing_array(hmacInputLen);
if (!hmacInput) {
LOG_ERROR("EncryptedStorage: OOM for DEK HMAC verify");
return false;
}
memcpy(hmacInput.get(), DEK_AUTH_LABEL, authLabelLen);
memcpy(hmacInput.get() + authLabelLen, nonce, NONCE_SIZE);
memcpy(hmacInput.get() + authLabelLen + NONCE_SIZE, encDek, AES_KEY_SIZE);
meshtastic_security::ZeroizingBuffer<HMAC_SIZE> expectedHmac;
nRFCrypto.begin();
bool hmacOk = computeHMAC(kek, AES_KEY_SIZE, hmacInput.get(), hmacInputLen, expectedHmac.data());
nRFCrypto.end();
hmacInput.reset();
if (!hmacOk) {
return false;
}
const uint8_t *storedHmac = buf + DEK_SIZE - HMAC_SIZE;
if (!constTimeEq(expectedHmac.data(), storedHmac, HMAC_SIZE)) {
LOG_ERROR("EncryptedStorage: DEK HMAC mismatch - wrong passphrase or tampered");
return false;
}
// Decrypt DEK into a local candidate - only write to global dek[] on success so that
// a wrong passphrase attempt does not destroy the live DEK in RAM.
meshtastic_security::ZeroizingBuffer<AES_KEY_SIZE> dekCandidate;
nRFCrypto.begin();
bool decOk = aesCtr128(kek, nonce, NONCE_SIZE, encDek, AES_KEY_SIZE, dekCandidate.data());
nRFCrypto.end();
if (!decOk) {
LOG_ERROR("EncryptedStorage: DEK decrypt failed");
return false;
}
memcpy(dek, dekCandidate.data(), AES_KEY_SIZE);
LOG_INFO("EncryptedStorage: DEK loaded and verified");
return true;
#else
return false;
#endif
}
/**
* Save the current in-RAM dek[] to disk as a DEK file, wrapped with kek[].
* Overwrites any existing DEK file.
*/
static bool saveDEK()
{
#ifdef FSCom
// Generate random nonce
uint8_t nonce[NONCE_SIZE];
nRFCrypto.begin();
if (!nRFCrypto.Random.generate(nonce, NONCE_SIZE)) {
LOG_ERROR("EncryptedStorage: TRNG failed for DEK nonce");
nRFCrypto.end();
return false;
}
// Encrypt DEK with KEK
uint8_t encDek[AES_KEY_SIZE];
bool encOk = aesCtr128(kek, nonce, NONCE_SIZE, dek, AES_KEY_SIZE, encDek);
if (!encOk) {
LOG_ERROR("EncryptedStorage: DEK encrypt failed");
nRFCrypto.end();
meshtastic_security::secure_zero(encDek, sizeof(encDek));
return false;
}
// Compute HMAC-SHA256(KEK, DEK_AUTH_LABEL || nonce || encDEK)
size_t authLabelLen = strlen(DEK_AUTH_LABEL);
size_t hmacInputLen = authLabelLen + NONCE_SIZE + AES_KEY_SIZE;
auto hmacInput = meshtastic_security::make_zeroizing_array(hmacInputLen);
if (!hmacInput) {
LOG_ERROR("EncryptedStorage: OOM for DEK HMAC");
nRFCrypto.end();
return false;
}
memcpy(hmacInput.get(), DEK_AUTH_LABEL, authLabelLen);
memcpy(hmacInput.get() + authLabelLen, nonce, NONCE_SIZE);
memcpy(hmacInput.get() + authLabelLen + NONCE_SIZE, encDek, AES_KEY_SIZE);
uint8_t hmac[HMAC_SIZE];
bool hmacOk = computeHMAC(kek, AES_KEY_SIZE, hmacInput.get(), hmacInputLen, hmac);
nRFCrypto.end();
hmacInput.reset();
if (!hmacOk) {
meshtastic_security::secure_zero(encDek, sizeof(encDek));
return false;
}
// Write file: magic(4)+nonce(13)+encDEK(16)+hmac(32) = 65 bytes
// H12 (audit): atomic write via SafeFile. Power-loss between remove()
// and write() previously left a missing or partial DEK file, which
// bricked the device - the encrypted protos can't be decrypted with
// no DEK on flash. SafeFile writes a tmp file, reads it back to verify
// a content hash, then atomically renames over the target. Crash before
// rename → old DEK stays in place; crash after rename → new DEK is on
// disk and verified.
uint32_t magic = DEK_MAGIC;
SafeFile sf(DEK_FILENAME, /*fullAtomic=*/true);
sf.write((uint8_t *)&magic, 4);
sf.write(nonce, NONCE_SIZE);
sf.write(encDek, AES_KEY_SIZE);
sf.write(hmac, HMAC_SIZE);
bool ok = sf.close();
meshtastic_security::secure_zero(nonce, sizeof(nonce));
meshtastic_security::secure_zero(encDek, sizeof(encDek));
meshtastic_security::secure_zero(hmac, sizeof(hmac));
if (!ok) {
LOG_ERROR("EncryptedStorage: DEK write/verify failed");
return false;
}
LOG_INFO("EncryptedStorage: DEK saved");
return true;
#else
return false;
#endif
}
// ---------------------------------------------------------------------------
// Internal helpers: unlock token I/O
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// M4 (audit): monotonic counter for token rollback protection
// ---------------------------------------------------------------------------
//
// Each new unlock token carries a uint32 counter inside its MAC'd body that
// strictly increases over the device's lifetime. The highest counter we've
// ever seen is persisted to /prefs/.tokmono and MAC'd with the FICR-only
// ephemeralKek under a distinct domain label, so a casual flash-write
// attacker (no flash extraction, no FICR access) cannot forge it.
//
// Rollback attempt: attacker captures token T1 at time T, operator unlocks
// later (token T2, counter > T1), attacker writes T1 back. readAndConsume
// sees T1.counter < max_seen and rejects as rollback.
static const char *MONO_FILENAME = "/prefs/.tokmono";
static const char *MONO_AUTH_LABEL = "tokmono-auth";
static constexpr size_t MONO_BODY_SIZE = 4;
static constexpr size_t MONO_TOTAL_SIZE = MONO_BODY_SIZE + HMAC_SIZE; // 36 bytes
// Compute HMAC-SHA256(ephemeralKek, MONO_AUTH_LABEL || body). Caller holds
// CC310 (nRFCrypto.begin/end).
static bool computeMonoHmac(const uint8_t body[MONO_BODY_SIZE], uint8_t out[HMAC_SIZE])
{
if (!ephemeralKekDerived)
return false;
size_t labelLen = strlen(MONO_AUTH_LABEL);
meshtastic_security::ZeroizingBuffer<32 + MONO_BODY_SIZE> input;
memcpy(input.data(), MONO_AUTH_LABEL, labelLen);
memcpy(input.data() + labelLen, body, MONO_BODY_SIZE);
return computeHMAC(ephemeralKek, AES_KEY_SIZE, input.data(), labelLen + MONO_BODY_SIZE, out);
}
// Read the persisted max-counter-seen value. Missing/short/MAC-fail
// returns 0 - the safe default that lets the next token write succeed and
// re-seed the file. Unlike the backoff file, missing-here is not a tamper
// signal: a fresh device or a device whose .tokmono got wiped (e.g. via
// factory-erase) legitimately has no counter file.
static uint32_t readMonoCounter()
{
#ifdef FSCom
meshtastic_security::ZeroizingBuffer<MONO_TOTAL_SIZE> buf;
{
concurrency::LockGuard g(spiLock);
auto f = FSCom.open(MONO_FILENAME, FILE_O_READ);
if (!f)
return 0;
size_t sz = f.size();
if (sz != MONO_TOTAL_SIZE) {
f.close();
return 0;
}
size_t n = f.read(buf.data(), MONO_TOTAL_SIZE);
f.close();
if (n != MONO_TOTAL_SIZE)
return 0;
}
uint8_t expected[HMAC_SIZE];
nRFCrypto.begin();
bool ok = computeMonoHmac(buf.data(), expected);
nRFCrypto.end();
if (!ok || !constTimeEq(expected, buf.data() + MONO_BODY_SIZE, HMAC_SIZE))
return 0;
uint32_t counter;
memcpy(&counter, buf.data(), 4);
return counter;
#else
return 0;
#endif
}
// Persist a new max-counter-seen value. Best-effort: log on failure but do
// not abort the caller (the token write that incremented the counter has
// already committed; a missing/stale .tokmono on the next read will be
// quietly promoted by readAndConsumeToken when it sees a token whose
// counter exceeds the persisted value).
static bool writeMonoCounter(uint32_t counter)
{
#ifdef FSCom
meshtastic_security::ZeroizingBuffer<MONO_TOTAL_SIZE> buf;
memcpy(buf.data(), &counter, 4);
uint8_t mac[HMAC_SIZE];
nRFCrypto.begin();
bool ok = computeMonoHmac(buf.data(), mac);
nRFCrypto.end();
if (!ok) {
LOG_ERROR("EncryptedStorage: mono-counter HMAC failed");
return false;
}
memcpy(buf.data() + MONO_BODY_SIZE, mac, HMAC_SIZE);
SafeFile sf(MONO_FILENAME, /*fullAtomic=*/true);
sf.write(buf.data(), MONO_TOTAL_SIZE);
if (!sf.close()) {
LOG_ERROR("EncryptedStorage: mono-counter atomic write failed");
return false;
}
return true;
#else
return false;
#endif
}
/**
* Write a new unlock token to TOKEN_FILENAME.
* Wraps the current in-RAM dek[] with ephemeralKek[].
* @param bootsRemaining Number of boots this token grants
* @param validUntilEpoch Unix timestamp after which token is invalid (0 = no limit)
* @param sessionMaxSeconds Uptime-based session cap per boot (0 = no cap).
* Persisted in the token so token-auto-unlock at
* cold boot inherits the same limit. Reboot
* starts a fresh session window - combined with
* bootsRemaining, gives a hard exposure ceiling
* bootsRemaining * sessionMaxSeconds.
*/
static bool writeUnlockToken(uint8_t bootsRemaining, uint32_t validUntilEpoch, uint32_t sessionMaxSeconds)
{
#ifdef FSCom
uint8_t nonce[NONCE_SIZE];
nRFCrypto.begin();
if (!nRFCrypto.Random.generate(nonce, NONCE_SIZE)) {
LOG_ERROR("EncryptedStorage: TRNG failed for token nonce");
nRFCrypto.end();
return false;
}
uint8_t encDek[AES_KEY_SIZE];
bool encOk = aesCtr128(ephemeralKek, nonce, NONCE_SIZE, dek, AES_KEY_SIZE, encDek);
if (!encOk) {
LOG_ERROR("EncryptedStorage: Token DEK encrypt failed");
nRFCrypto.end();
meshtastic_security::secure_zero(encDek, sizeof(encDek));
return false;
}
// M4 (audit): claim a fresh monotonic-counter slot ABOVE the highest
// value previously persisted. The new counter is MAC'd into the token
// body below; after the token write succeeds we persist this value to
// /prefs/.tokmono so the next readAndConsumeToken can reject any older
// token that gets restored to disk later.
uint32_t newMonoCounter = readMonoCounter() + 1;
// Build body for HMAC (everything before the trailing HMAC)
uint8_t body[TOKEN_BODY_SIZE];
size_t pos = 0;
uint32_t magic = TOKEN_MAGIC;
memcpy(body + pos, &magic, 4);
pos += 4;
memcpy(body + pos, nonce, NONCE_SIZE);
pos += NONCE_SIZE;
memcpy(body + pos, encDek, AES_KEY_SIZE);
pos += AES_KEY_SIZE;
body[pos++] = bootsRemaining;
memcpy(body + pos, &validUntilEpoch, 4);
pos += 4;
memcpy(body + pos, &sessionMaxSeconds, 4);
pos += 4;
memcpy(body + pos, &newMonoCounter, 4);
pos += 4;
uint8_t hmac[HMAC_SIZE];
bool hmacOk = computeHMAC(ephemeralKek, AES_KEY_SIZE, body, TOKEN_BODY_SIZE, hmac);
nRFCrypto.end();
meshtastic_security::secure_zero(encDek, sizeof(encDek));
if (!hmacOk) {
meshtastic_security::secure_zero(body, sizeof(body));
return false;
}
// H12 (audit): atomic token write via SafeFile (see saveDEK note for
// the same rationale). Power-loss between remove and write previously
// left the token in an unreadable state, forcing the operator to re-
// enter the passphrase from a client. SafeFile rolls back to the
// previous token if the new write fails verification.
SafeFile sf(TOKEN_FILENAME, /*fullAtomic=*/true);
sf.write(body, TOKEN_BODY_SIZE);
sf.write(hmac, HMAC_SIZE);
bool tokOk = sf.close();
if (!tokOk) {
LOG_ERROR("EncryptedStorage: token write/verify failed");
meshtastic_security::secure_zero(body, sizeof(body));
meshtastic_security::secure_zero(hmac, sizeof(hmac));
return false;
}
meshtastic_security::secure_zero(body, sizeof(body));
meshtastic_security::secure_zero(hmac, sizeof(hmac));
// M4: persist new max-counter-seen AFTER the token write committed.
// If this write fails the token is still valid (its counter is
// greater than the persisted value); readAndConsumeToken will
// promote .tokmono on the next read.
if (!writeMonoCounter(newMonoCounter)) {
LOG_WARN("EncryptedStorage: mono-counter persist failed (self-heals on next read)");
}
LOG_INFO("EncryptedStorage: Unlock token written (boots=%d, epoch=%u, mono=%u)", bootsRemaining, validUntilEpoch,
(unsigned)newMonoCounter);
return true;
#else
return false;
#endif
}
/**
* Read, validate, and consume the unlock token.
* If valid: decrypts DEK into dek[], decrements boot count, rewrites token (or deletes if boots==0).
* Returns true if the token was valid and DEK was loaded.
*/
static bool readAndConsumeToken()
{
#ifdef FSCom
// Read the token file. M10 (audit): the 74-byte buffer holds the entire
// wrapped DEK + HMAC; using ZeroizingBuffer ensures the destructor
// wipes it on every return path (success and all the error cases
// below) without needing one secure_zero per goto-label.
meshtastic_security::ZeroizingBuffer<TOKEN_TOTAL_SIZE> buf;
{
concurrency::LockGuard g(spiLock);
auto f = FSCom.open(TOKEN_FILENAME, FILE_O_READ);
if (!f)
return false;
size_t fileSize = f.size();
if (fileSize != TOKEN_TOTAL_SIZE) {
f.close();
LOG_WARN("EncryptedStorage: Token file wrong size (%d), deleting", fileSize);
FSCom.remove(TOKEN_FILENAME);
lockReason = "token_wrong_size";
return false;
}
size_t bytesRead = f.read(buf.data(), TOKEN_TOTAL_SIZE);
f.close();
if (bytesRead != TOKEN_TOTAL_SIZE) {
LOG_ERROR("EncryptedStorage: Token short read");
FSCom.remove(TOKEN_FILENAME);
return false;
}
}
// Verify magic
uint32_t magic;
memcpy(&magic, buf.data(), 4);
if (magic != TOKEN_MAGIC) {
LOG_ERROR("EncryptedStorage: Token bad magic, deleting");
concurrency::LockGuard g(spiLock);
FSCom.remove(TOKEN_FILENAME);
lockReason = "token_bad_magic";
return false;
}
// Verify HMAC-SHA256(ephemeralKek, body). M10: ZeroizingBuffer wipes on scope exit.
meshtastic_security::ZeroizingBuffer<HMAC_SIZE> computedHmac;
nRFCrypto.begin();
bool hmacOk = computeHMAC(ephemeralKek, AES_KEY_SIZE, buf.data(), TOKEN_BODY_SIZE, computedHmac.data());
nRFCrypto.end();
if (!hmacOk || !constTimeEq(computedHmac.data(), buf.data() + TOKEN_BODY_SIZE, HMAC_SIZE)) {
LOG_ERROR("EncryptedStorage: Token HMAC failed - tampered or wrong device, deleting");
concurrency::LockGuard g(spiLock);
FSCom.remove(TOKEN_FILENAME);
lockReason = "token_hmac_fail";
return false;
}
// Parse fields from body
size_t pos = 4; // skip magic
const uint8_t *nonce = buf.data() + pos;
pos += NONCE_SIZE;
const uint8_t *encDek = buf.data() + pos;
pos += AES_KEY_SIZE;
uint8_t bootsRemaining = buf[pos++];
uint32_t validUntilEpoch;
memcpy(&validUntilEpoch, buf.data() + pos, 4);
pos += 4;
uint32_t sessionMaxSeconds;
memcpy(&sessionMaxSeconds, buf.data() + pos, 4);
pos += 4;
uint32_t tokenMonoCounter;
memcpy(&tokenMonoCounter, buf.data() + pos, 4);
// M4 (audit): reject any token whose monotonic counter is below the
// persisted max-seen. An attacker who once read disk could otherwise
// restore a higher-bootcount / weaker-policy token even after the
// operator unlocked again with tighter parameters; this check makes
// such a restore visible and fatal at boot.
//
// If the token's counter is GREATER than what we've persisted (e.g.
// the .tokmono file was lost via factory-erase, or the persist after
// a token write itself failed), accept and promote .tokmono to the
// current value. Equal is the normal case post-write.
uint32_t maxSeenCounter = readMonoCounter();
if (tokenMonoCounter < maxSeenCounter) {
LOG_ERROR("EncryptedStorage: Token rollback (counter=%u, max-seen=%u), deleting", (unsigned)tokenMonoCounter,
(unsigned)maxSeenCounter);
concurrency::LockGuard g(spiLock);
FSCom.remove(TOKEN_FILENAME);
lockReason = "token_rollback";
return false;
}
if (tokenMonoCounter > maxSeenCounter) {
// Self-heal: this token is newer than what we knew, promote it.
writeMonoCounter(tokenMonoCounter);
}
// Check boot count
if (bootsRemaining == 0) {
LOG_WARN("EncryptedStorage: Token boot count exhausted, deleting");
concurrency::LockGuard g(spiLock);
FSCom.remove(TOKEN_FILENAME);
lockReason = "token_boots_zero";
return false;
}
// Check time expiry. A wall-clock TTL (validUntilEpoch != 0) needs a
// currently valid RTC to verify. getValidTime() returns 0 unless we
// actually have an RTC source - getTime() would return a boot-relative
// count, which an attacker can reset by power-cycling with no RTC sync.
//
// If the wall-clock TTL is set but we can't verify it right now:
// - boot count still has budget -> fall back to the boot-count TTL,
// keep the token. The boot count is independently verifiable
// without an RTC, so the token is not unbounded.
// - boot count is the only thing we had and it's zero -> already
// rejected above. (validUntilEpoch is never the *sole* TTL here:
// bootsRemaining > 0 is guaranteed by the check above.)
// We only hard-reject (delete) a token whose wall-clock TTL we *can*
// evaluate and find expired.
if (validUntilEpoch != 0) {
uint32_t now = getValidTime(RTCQualityDevice);
if (now == 0) {
LOG_WARN("EncryptedStorage: Token wall-clock TTL unverifiable (no RTC), using boot count (%u left)", bootsRemaining);
} else if (now > validUntilEpoch) {
LOG_WARN("EncryptedStorage: Token expired (now=%u, until=%u), deleting", now, validUntilEpoch);
concurrency::LockGuard g(spiLock);
FSCom.remove(TOKEN_FILENAME);
lockReason = "token_expired";
return false;
}
}
// Decrypt DEK from token
nRFCrypto.begin();
bool decOk = aesCtr128(ephemeralKek, nonce, NONCE_SIZE, encDek, AES_KEY_SIZE, dek);
nRFCrypto.end();
if (!decOk) {
LOG_ERROR("EncryptedStorage: Token DEK decrypt failed");
meshtastic_security::secure_zero(dek, sizeof(dek));
concurrency::LockGuard g(spiLock);
FSCom.remove(TOKEN_FILENAME);
lockReason = "token_dek_fail";
return false;
}
// Decrement boot count and rewrite (or delete if now zero)
uint8_t newBoots = bootsRemaining - 1;
if (newBoots == 0) {
LOG_INFO("EncryptedStorage: Token last boot consumed, deleting");
concurrency::LockGuard g(spiLock);
FSCom.remove(TOKEN_FILENAME);
} else {
writeUnlockToken(newBoots, validUntilEpoch, sessionMaxSeconds);
}
dekLoaded = true;
s_bootsRemaining = newBoots;
s_validUntilEpoch = validUntilEpoch;
// Start the session timer if the token carries one. Token-auto-unlocked
// boots inherit the same cap that was set at passphrase-unlock time.
setSession(sessionMaxSeconds);
LOG_INFO("EncryptedStorage: Token valid, DEK loaded (%d boots remaining%s)", newBoots,
sessionMaxSeconds ? ", session timer armed" : "");
return true;
#else
return false;
#endif
}
// ---------------------------------------------------------------------------
// Internal helpers: DEK generation
// ---------------------------------------------------------------------------
static bool generateDEK()
{
nRFCrypto.begin();
bool ok = nRFCrypto.Random.generate(dek, AES_KEY_SIZE);
nRFCrypto.end();
if (!ok) {
LOG_ERROR("EncryptedStorage: TRNG failed generating DEK");
meshtastic_security::secure_zero(dek, sizeof(dek));
return false;
}
return true;
}
// ---------------------------------------------------------------------------