memseal 0.1.6

A small password-based encrypted vault for named secrets
Documentation
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
//! High-level API for creating, opening, and managing encrypted vaults.
//!
//! # Examples
//!
//! ```
//! use memseal::Vault;
//!
//! let mut vault = Vault::create(b"my-password-here").unwrap();
//!
//! vault.store("api_key", b"sk-secret-12345").unwrap();
//! assert_eq!(
//!     vault.retrieve("api_key").unwrap(),
//!     Some(b"sk-secret-12345".to_vec())
//! );
//!
//! let bytes = vault.export().unwrap();
//! let reopened = Vault::open(b"my-password-here", &bytes).unwrap();
//! assert_eq!(
//!     reopened.retrieve("api_key").unwrap(),
//!     Some(b"sk-secret-12345".to_vec())
//! );
//! ```

use crate::constants::argon2::KEY_LEN;
use crate::constants::xchacha20_poly1305::XCHACHA20_NONCE_LEN;
use crate::constants::{
    MAX_ENTRY_DATA_SIZE, MAX_ENTRY_NAME_LEN, MIN_KDF_ITERATIONS, MIN_KDF_MEMORY, MIN_PASSWORD_LEN,
    SUPPORTED_VAULT_VERSIONS, VAULT_VERSION,
};
use crate::crypto::aad_aead::{open_with_aad, seal_with_aad};
use crate::crypto::utils::secure_bytes_fill;
use crate::mem::secure_memory_vault::{MemoryVaultError, SecureMemoryVault};
use crate::vault::vault_error::VaultError;
use crate::vault::vault_header::VaultHeader;
use crate::vault::vault_index::{
    IndexMetaBlockLocation, IndexMetaBlockMetadata, VaultIndex, derive_subkeys,
};
use orion::hazardous::kdf::argon2i;
use std::io::Read as IoRead;
use std::path::Path;
use zeroize::Zeroize;

const MAX_KDF_MEMORY: u32 = 4_194_304; // 4 GiB
const MAX_KDF_ITERATIONS: u32 = 100;
const MAX_VAULT_FILE_SIZE: u64 = 256 * 1024 * 1024; // 256 MiB

/// An encrypted in-memory vault for storing named secrets.
///
/// Secrets are encrypted with XChaCha20-Poly1305, keys are derived from a
/// password via Argon2i, and all key material is zeroized on drop.
///
/// # Examples
///
/// ```
/// use memseal::Vault;
///
/// let mut vault = Vault::create(b"password1234").unwrap();
/// vault.store("db_url", b"postgres://localhost/mydb").unwrap();
///
/// # let dir = std::env::temp_dir();
/// # let path = dir.join("test_vault_doc2.seal");
/// vault.save(&path).unwrap();
///
/// let loaded = Vault::load(&path, b"password1234").unwrap();
/// assert_eq!(
///     loaded.retrieve("db_url").unwrap(),
///     Some(b"postgres://localhost/mydb".to_vec())
/// );
/// # std::fs::remove_file(&path).ok();
/// ```
pub struct Vault {
    header: VaultHeader,
    index: VaultIndex<crate::crypto::nonce_rotation::NonceNotRotated>,
}

impl Vault {
    /// Creates a new empty vault protected by the given password.
    ///
    /// Password must be at least 8 bytes.
    pub fn create(password: &[u8]) -> Result<Self, VaultError> {
        validate_password(password)?;
        let header = VaultHeader::generate()?;
        let mut master_key = derive_master_key(password, &header)?;

        let result = VaultIndex::from_master_key(&master_key, &header.kdf_salt)
            .map_err(|e| VaultError::CryptoError(e.to_string()));

        master_key.zeroize();
        Ok(Vault {
            header,
            index: result?,
        })
    }

    /// Opens an existing vault from exported bytes.
    ///
    /// Returns [`VaultError::InvalidPassword`] if the password is wrong.
    pub fn open(password: &[u8], data: &[u8]) -> Result<Self, VaultError> {
        if data.len() < 4 {
            return Err(VaultError::CorruptedData("Data too short".to_string()));
        }

        let header_len = u32::from_le_bytes(data[..4].try_into().unwrap()) as usize;

        if header_len > MAX_VAULT_FILE_SIZE as usize {
            return Err(VaultError::CorruptedData(
                "Header length too large".to_string(),
            ));
        }

        let after_header = 4usize
            .checked_add(header_len)
            .ok_or(VaultError::CorruptedData(
                "Header length overflow".to_string(),
            ))?;

        let min_total = after_header
            .checked_add(XCHACHA20_NONCE_LEN + 8)
            .ok_or(VaultError::CorruptedData("Size overflow".to_string()))?;

        if data.len() < min_total {
            return Err(VaultError::CorruptedData(
                "Data too short for nonce and counter".to_string(),
            ));
        }

        let header: VaultHeader = serde_json::from_slice(&data[4..after_header])
            .map_err(|e| VaultError::CorruptedData(format!("Invalid header JSON: {}", e)))?;

        validate_header(&header)?;

        let nonce: [u8; XCHACHA20_NONCE_LEN] = data
            [after_header..after_header + XCHACHA20_NONCE_LEN]
            .try_into()
            .unwrap();

        let counter_start = after_header + XCHACHA20_NONCE_LEN;
        let encrypted_index = &data[counter_start + 8..];

        let mut master_key = derive_master_key(password, &header)?;
        let mut enc_sub = [0u8; 32];

        let result = (|| -> Result<Self, VaultError> {
            let (e, _h) = derive_subkeys(&master_key, &header.kdf_salt)
                .map_err(|e| VaultError::CryptoError(e.to_string()))?;
            enc_sub = e;

            let aad = header.to_aad_bytes()?;
            // Wrap in Zeroizing so the decrypted index (HMAC entry names, nonce
            // counters, structural metadata) is cleared on scope exit, matching
            // how master_key and enc_sub are handled below.
            let index_json = zeroize::Zeroizing::new(
                open_with_aad(&enc_sub, &nonce, encrypted_index, &aad)
                    .map_err(|_| VaultError::InvalidPassword)?,
            );

            #[derive(serde::Deserialize)]
            struct IndexData {
                version: u16,
                nonce: [u8; XCHACHA20_NONCE_LEN],
                nonce_counter: u64,
                data_nonce_counter: u64,
                files: std::collections::HashMap<String, IndexMetaBlockMetadata>,
            }

            let idx_data: IndexData = serde_json::from_slice(&index_json)
                .map_err(|e| VaultError::CorruptedData(format!("Invalid index JSON: {}", e)))?;

            if !crate::constants::vault_index_constants::SUPPORTED_VAULT_INDEX_VERSIONS
                .contains(&idx_data.version)
            {
                return Err(VaultError::CorruptedData(format!(
                    "Unsupported index version: {}",
                    idx_data.version
                )));
            }

            let max_index_entries = crate::constants::vault_index_constants::MAX_INDEX_ENTRIES;
            if idx_data.files.len() > max_index_entries {
                return Err(VaultError::CorruptedData(format!(
                    "Index entry count {} exceeds maximum {}",
                    idx_data.files.len(),
                    max_index_entries
                )));
            }

            let index = VaultIndex::from_master_key_and_data(
                &master_key,
                &header.kdf_salt,
                idx_data.nonce,
                idx_data.nonce_counter,
                idx_data.data_nonce_counter,
                idx_data.files,
            )
            .map_err(|e| VaultError::CryptoError(e.to_string()))?;

            Ok(Vault { header, index })
        })();

        master_key.zeroize();
        enc_sub.zeroize();
        result
    }

    /// Loads a vault from a file on disk.
    ///
    /// Reads at most 256 MiB to prevent resource exhaustion.
    pub fn load(path: &Path, password: &[u8]) -> Result<Self, VaultError> {
        let file = std::fs::File::open(path)?;
        let mut limited = file.take(MAX_VAULT_FILE_SIZE + 1);
        let mut data = Vec::new();
        limited.read_to_end(&mut data)?;
        if data.len() as u64 > MAX_VAULT_FILE_SIZE {
            return Err(VaultError::CorruptedData(format!(
                "Vault file too large (max {} bytes)",
                MAX_VAULT_FILE_SIZE
            )));
        }
        Self::open(password, &data)
    }

    /// Stores a named secret in the vault, encrypting it immediately.
    ///
    /// Name must be at most 255 bytes. Data must be at most 64 MiB.
    /// If a secret with the same name already exists, it is overwritten.
    pub fn store(&mut self, name: &str, plaintext: &[u8]) -> Result<(), VaultError> {
        if name.len() > MAX_ENTRY_NAME_LEN {
            return Err(VaultError::CryptoError(format!(
                "Entry name too long: {} bytes (max {})",
                name.len(),
                MAX_ENTRY_NAME_LEN
            )));
        }
        if plaintext.len() > MAX_ENTRY_DATA_SIZE {
            return Err(VaultError::CryptoError(format!(
                "Entry data too large: {} bytes (max {})",
                plaintext.len(),
                MAX_ENTRY_DATA_SIZE
            )));
        }

        let data_counter = self
            .index
            .next_data_nonce_counter()
            .map_err(|e| VaultError::CryptoError(e.to_string()))?;

        // Random nonces from the OS CSPRNG. They are stored as the prefix of
        // each ciphertext, so they never need to be re-derived; randomness
        // also rules out reuse when two vault instances are opened from the
        // same persisted state.
        let mut data_nonce = [0u8; XCHACHA20_NONCE_LEN];
        secure_bytes_fill(&mut data_nonce).map_err(|e| VaultError::CryptoError(e.to_string()))?;
        let mut name_nonce = [0u8; XCHACHA20_NONCE_LEN];
        secure_bytes_fill(&mut name_nonce).map_err(|e| VaultError::CryptoError(e.to_string()))?;

        // Compute HMAC'd key for AAD binding (prevents entry-swap attacks)
        let hmac_key = self
            .index
            .lookup_hmac_key_for_name(name)
            .map_err(|e| VaultError::CryptoError(e.to_string()))?;
        let entry_aad = build_entry_aad(&hmac_key, data_counter);

        let enc_vault = self.index.enc_key().ok_or(VaultError::InvalidKey)?;
        let mut enc_key_bytes = extract_enc_key(enc_vault)?;

        let result = (|| -> Result<(), VaultError> {
            let ciphertext = seal_with_aad(&enc_key_bytes, &data_nonce, plaintext, &entry_aad)
                .map_err(|e| VaultError::CryptoError(e.to_string()))?;

            let encrypted_name_ct =
                seal_with_aad(&enc_key_bytes, &name_nonce, name.as_bytes(), &entry_aad)
                    .map_err(|e| VaultError::CryptoError(e.to_string()))?;

            let mut encrypted = Vec::with_capacity(XCHACHA20_NONCE_LEN + ciphertext.len());
            encrypted.extend_from_slice(&data_nonce);
            encrypted.extend_from_slice(&ciphertext);

            let mut enc_name = Vec::with_capacity(XCHACHA20_NONCE_LEN + encrypted_name_ct.len());
            enc_name.extend_from_slice(&name_nonce);
            enc_name.extend_from_slice(&encrypted_name_ct);

            let metadata = IndexMetaBlockMetadata::new(
                IndexMetaBlockLocation::Inline,
                0,
                0,
                false,
                Some(encrypted),
                Some(enc_name),
                data_counter,
            );

            self.index
                .insert_file(name, metadata)
                .map_err(|e| VaultError::CryptoError(e.to_string()))?;

            Ok(())
        })();

        enc_key_bytes.zeroize();
        result
    }

    /// Retrieves a secret by name, decrypting it.
    ///
    /// Returns `Ok(None)` if no secret with that name exists.
    pub fn retrieve(&self, name: &str) -> Result<Option<Vec<u8>>, VaultError> {
        let hmac_key = self
            .index
            .lookup_hmac_key_for_name(name)
            .map_err(|e| VaultError::CryptoError(e.to_string()))?;

        let meta = self
            .index
            .lookup_file(name)
            .map_err(|e| VaultError::CryptoError(e.to_string()))?;

        let meta = match meta {
            Some(m) => m,
            None => return Ok(None),
        };

        let encrypted = match &meta.encrypted_data {
            Some(d) => d,
            None => return Ok(None),
        };

        if encrypted.len() < XCHACHA20_NONCE_LEN {
            return Err(VaultError::CorruptedData(
                "Encrypted data too short for nonce".to_string(),
            ));
        }

        let enc_vault = self.index.enc_key().ok_or(VaultError::InvalidKey)?;

        let mut enc_key_bytes = extract_enc_key(enc_vault)?;

        let nonce: [u8; XCHACHA20_NONCE_LEN] = encrypted[..XCHACHA20_NONCE_LEN].try_into().unwrap();
        let ciphertext = &encrypted[XCHACHA20_NONCE_LEN..];

        let entry_aad = build_entry_aad(&hmac_key, meta.data_counter);

        let plaintext = open_with_aad(&enc_key_bytes, &nonce, ciphertext, &entry_aad)
            .map_err(|e| VaultError::CryptoError(e.to_string()));

        enc_key_bytes.zeroize();
        Ok(Some(plaintext?))
    }

    /// Removes a secret by name. Returns `true` if it existed.
    pub fn remove(&mut self, name: &str) -> Result<bool, VaultError> {
        let removed = self
            .index
            .remove_file(name)
            .map_err(|e| VaultError::CryptoError(e.to_string()))?;

        if let Some(mut meta) = removed {
            if let Some(ref mut data) = meta.encrypted_data {
                data.zeroize();
            }
            if let Some(ref mut name_data) = meta.encrypted_name {
                name_data.zeroize();
            }
            Ok(true)
        } else {
            Ok(false)
        }
    }

    /// Serializes the vault to bytes for persistence.
    ///
    /// Each call generates a fresh random index nonce and advances the
    /// authenticated nonce counter.
    ///
    /// Returns [`VaultError::SerializationError`] if the serialized vault
    /// exceeds the 256 MiB file-size bound enforced by [`Vault::load`]; this
    /// guarantees that anything `export()` produces can be loaded back.
    /// Encrypted entry bytes are stored in the index JSON as number arrays
    /// (roughly 3.6 output bytes per stored byte), so the practical bound on
    /// total stored plaintext is about 70 MiB. The vault itself is left
    /// intact by this error; remove entries and export again.
    pub fn export(&mut self) -> Result<Vec<u8>, VaultError> {
        self.index
            .advance_nonce()
            .map_err(|e| VaultError::CryptoError(e.to_string()))?;

        // Wrap in Zeroizing so the serialized header and index buffers are
        // cleared on scope exit. header_json carries only public KDF params,
        // but index_json carries structural metadata; clear both for
        // consistency with the rest of the key-handling in this file.
        let header_json = zeroize::Zeroizing::new(
            serde_json::to_vec(&self.header)
                .map_err(|e| VaultError::SerializationError(e.to_string()))?,
        );

        let index_json = zeroize::Zeroizing::new(
            serde_json::to_vec(&self.index)
                .map_err(|e| VaultError::SerializationError(e.to_string()))?,
        );

        let enc_vault = self.index.enc_key().ok_or(VaultError::InvalidKey)?;
        let mut enc_key_bytes = extract_enc_key(enc_vault)?;

        let aad = self.header.to_aad_bytes()?;
        let encrypted_index = seal_with_aad(&enc_key_bytes, &self.index.nonce, &index_json, &aad)
            .map_err(|e| VaultError::CryptoError(e.to_string()));

        enc_key_bytes.zeroize();
        let encrypted_index = encrypted_index?;

        let header_len = (header_json.len() as u32).to_le_bytes();

        let mut output = Vec::with_capacity(
            4 + header_json.len() + XCHACHA20_NONCE_LEN + 8 + encrypted_index.len(),
        );
        output.extend_from_slice(&header_len);
        output.extend_from_slice(&header_json);
        output.extend_from_slice(&self.index.nonce);
        output.extend_from_slice(&self.index.nonce_counter.to_le_bytes());
        output.extend_from_slice(&encrypted_index);

        validate_export_size(output.len() as u64)?;

        Ok(output)
    }

    /// Saves the vault to a file on disk.
    ///
    /// Uses atomic write (temp file + rename) with 0600 permissions on Unix.
    pub fn save(&mut self, path: &Path) -> Result<(), VaultError> {
        use std::io::Write;

        let data = self.export()?;
        let dir = path.parent().unwrap_or(Path::new("."));

        let mut rand_suffix = [0u8; 8];
        secure_bytes_fill(&mut rand_suffix).map_err(|e| VaultError::CryptoError(e.to_string()))?;
        let hex_suffix: String = rand_suffix.iter().map(|b| format!("{:02x}", b)).collect();
        let tmp_path = dir.join(format!(".memseal_tmp_{}", hex_suffix));

        let mut file = {
            let mut opts = std::fs::OpenOptions::new();
            opts.write(true).create_new(true);

            #[cfg(unix)]
            {
                use std::os::unix::fs::OpenOptionsExt;
                opts.mode(0o600);
            }

            opts.open(&tmp_path)?
        };

        let write_result = file.write_all(&data).and_then(|_| file.sync_all());
        drop(file);
        if let Err(e) = write_result {
            let _ = std::fs::remove_file(&tmp_path);
            return Err(e.into());
        }

        std::fs::rename(&tmp_path, path).inspect_err(|_| {
            let _ = std::fs::remove_file(&tmp_path);
        })?;

        #[cfg(unix)]
        if let Ok(dir_file) = std::fs::File::open(dir) {
            let _ = dir_file.sync_all();
        }

        Ok(())
    }

    /// Changes the vault's password.
    ///
    /// Re-derives all keys from the new password and re-encrypts every entry
    /// one at a time (at most one plaintext in memory at any given time).
    pub fn change_password(
        &mut self,
        current_password: &[u8],
        new_password: &[u8],
    ) -> Result<(), VaultError> {
        validate_password(new_password)?;

        // Verify current password by exporting and re-opening.
        // Note: export() advances the nonce counter on self. If the password
        // check fails, self has a different nonce but is otherwise unchanged
        // and fully functional. This is acceptable because the nonce counter
        // is monotonic and the vault data is intact.
        let mut exported = self.export()?;
        let _ = Vault::open(current_password, &exported)?;
        exported.zeroize();

        let new_header = VaultHeader::generate()?;
        let mut new_master_key = derive_master_key(new_password, &new_header)?;

        // Collect encrypted entries with HMAC'd keys and data_counter for AAD
        type OldEntry = (String, u64, bool, Option<Vec<u8>>, Option<Vec<u8>>);
        let old_entries: Vec<OldEntry> = self
            .index
            .files
            .iter()
            .map(|(k, m)| {
                (
                    k.clone(),
                    m.data_counter,
                    m.is_dummy,
                    m.encrypted_name.clone(),
                    m.encrypted_data.clone(),
                )
            })
            .collect();

        let old_enc_vault = self.index.enc_key().ok_or(VaultError::InvalidKey)?;
        let mut old_enc_key = extract_enc_key(old_enc_vault).inspect_err(|_| {
            new_master_key.zeroize();
        })?;

        // Build new vault - zeroize both keys on any failure
        let new_index_result = VaultIndex::from_master_key(&new_master_key, &new_header.kdf_salt)
            .map_err(|e| VaultError::CryptoError(e.to_string()));
        new_master_key.zeroize();
        let new_index = match new_index_result {
            Ok(idx) => idx,
            Err(e) => {
                old_enc_key.zeroize();
                return Err(e);
            }
        };

        let mut new_vault = Vault {
            header: new_header,
            index: new_index,
        };

        // Re-encrypt entries one at a time into new_vault
        let loop_result = (|| -> Result<(), VaultError> {
            for (old_hmac_key, old_counter, is_dummy, enc_name_opt, enc_data_opt) in &old_entries {
                // Dummy padding entries carry no payload; they are dropped on
                // password change rather than re-encrypted.
                if *is_dummy {
                    continue;
                }

                // A real entry always stores both ciphertexts; a missing or
                // undersized field means the (authenticated) index is
                // inconsistent. Fail instead of silently dropping the entry.
                let old_aad = build_entry_aad(old_hmac_key, *old_counter);

                let mut plaintext_name = match enc_name_opt {
                    Some(enc_name) if enc_name.len() >= XCHACHA20_NONCE_LEN => {
                        let nonce: [u8; XCHACHA20_NONCE_LEN] =
                            enc_name[..XCHACHA20_NONCE_LEN].try_into().unwrap();
                        let ct = &enc_name[XCHACHA20_NONCE_LEN..];
                        let name_bytes = open_with_aad(&old_enc_key, &nonce, ct, &old_aad)
                            .map_err(|e| VaultError::CryptoError(e.to_string()))?;
                        String::from_utf8(name_bytes).map_err(|e| {
                            let mut bytes = e.into_bytes();
                            bytes.zeroize();
                            VaultError::CorruptedData("Invalid entry name".to_string())
                        })?
                    }
                    _ => {
                        return Err(VaultError::CorruptedData(
                            "Entry is missing a valid encrypted name".to_string(),
                        ));
                    }
                };

                let encrypted = match enc_data_opt {
                    Some(enc) if enc.len() >= XCHACHA20_NONCE_LEN => enc,
                    _ => {
                        plaintext_name.zeroize();
                        return Err(VaultError::CorruptedData(
                            "Entry is missing valid encrypted data".to_string(),
                        ));
                    }
                };

                let nonce: [u8; XCHACHA20_NONCE_LEN] =
                    encrypted[..XCHACHA20_NONCE_LEN].try_into().unwrap();
                let ciphertext = &encrypted[XCHACHA20_NONCE_LEN..];
                let mut plaintext = open_with_aad(&old_enc_key, &nonce, ciphertext, &old_aad)
                    .map_err(|e| VaultError::CryptoError(e.to_string()))?;

                let store_result = new_vault.store(&plaintext_name, &plaintext);
                plaintext.zeroize();
                plaintext_name.zeroize();
                store_result?;
            }
            Ok(())
        })();

        old_enc_key.zeroize();

        // Only swap on full success - on error, self retains old keys/data
        // (nonce counter may have advanced from the export() verification above)
        loop_result?;
        self.header = new_vault.header;
        self.index = new_vault.index;
        Ok(())
    }
}

fn validate_password(password: &[u8]) -> Result<(), VaultError> {
    if password.len() < MIN_PASSWORD_LEN {
        return Err(VaultError::CryptoError(format!(
            "Password must be at least {} bytes",
            MIN_PASSWORD_LEN
        )));
    }
    Ok(())
}

fn validate_header(header: &VaultHeader) -> Result<(), VaultError> {
    if !SUPPORTED_VAULT_VERSIONS.contains(&header.version) {
        return Err(VaultError::CorruptedData(format!(
            "Unsupported vault version: {} (supported: {})",
            header.version, VAULT_VERSION
        )));
    }
    if header.kdf_memory_cost > MAX_KDF_MEMORY || header.kdf_memory_cost < MIN_KDF_MEMORY {
        return Err(VaultError::CorruptedData(format!(
            "KDF memory cost out of range: {} KiB (allowed {}-{})",
            header.kdf_memory_cost, MIN_KDF_MEMORY, MAX_KDF_MEMORY
        )));
    }
    if header.kdf_iterations > MAX_KDF_ITERATIONS || header.kdf_iterations < MIN_KDF_ITERATIONS {
        return Err(VaultError::CorruptedData(format!(
            "KDF iterations out of range: {} (allowed {}-{})",
            header.kdf_iterations, MIN_KDF_ITERATIONS, MAX_KDF_ITERATIONS
        )));
    }
    if header.key_length != KEY_LEN {
        return Err(VaultError::CorruptedData(format!(
            "Invalid key length: {} (expected {})",
            header.key_length, KEY_LEN
        )));
    }
    Ok(())
}

/// Rejects exports larger than the bound `Vault::load` enforces, so a vault
/// that `save()` writes can always be loaded back.
fn validate_export_size(len: u64) -> Result<(), VaultError> {
    if len > MAX_VAULT_FILE_SIZE {
        return Err(VaultError::SerializationError(format!(
            "Exported vault is {} bytes, exceeding the maximum vault file size of {} bytes; remove entries before exporting",
            len, MAX_VAULT_FILE_SIZE
        )));
    }
    Ok(())
}

fn build_entry_aad(hmac_key: &str, counter: u64) -> Vec<u8> {
    let key_bytes = hmac_key.as_bytes();
    let counter_bytes = counter.to_le_bytes();
    let mut aad = Vec::with_capacity(key_bytes.len() + 8);
    aad.extend_from_slice(key_bytes);
    aad.extend_from_slice(&counter_bytes);
    aad
}

/// Copies the 32-byte encryption subkey out of the secure memory vault.
///
/// The subkey is always exactly 32 bytes (HKDF-SHA256 output). A shorter
/// chunk is treated as a hard error rather than being silently ignored, which
/// would otherwise leave the key buffer all-zero and lead to encryption or
/// decryption under a known zero key.
fn extract_enc_key(enc_vault: &SecureMemoryVault) -> Result<[u8; 32], VaultError> {
    let mut enc_key_bytes = [0u8; 32];
    let mut copied = false;
    enc_vault
        .access(|chunk, _tag| {
            if chunk.len() < 32 {
                return Err(MemoryVaultError::GenericError(
                    "encryption subkey shorter than 32 bytes".to_string(),
                ));
            }
            enc_key_bytes.copy_from_slice(&chunk[..32]);
            copied = true;
            Ok(())
        })
        .map_err(|e| {
            enc_key_bytes.zeroize();
            VaultError::CryptoError(e.to_string())
        })?;
    if !copied {
        enc_key_bytes.zeroize();
        return Err(VaultError::CryptoError(
            "encryption subkey unavailable".to_string(),
        ));
    }
    Ok(enc_key_bytes)
}

fn derive_master_key(password: &[u8], header: &VaultHeader) -> Result<[u8; KEY_LEN], VaultError> {
    let mut master_key = [0u8; KEY_LEN];
    argon2i::derive_key(
        password,
        &header.kdf_salt,
        header.kdf_iterations,
        header.kdf_memory_cost,
        None,
        None,
        &mut master_key,
    )
    .map_err(|e| VaultError::CryptoError(format!("Argon2i derivation failed: {}", e)))?;
    Ok(master_key)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extract_enc_key_returns_32_bytes_for_valid_subkey() {
        let key = [7u8; 32];
        let vault = SecureMemoryVault::new(&key).unwrap();
        let extracted = extract_enc_key(&vault).unwrap();
        assert_eq!(extracted, key);
    }

    #[test]
    fn extract_enc_key_rejects_short_subkey() {
        // A subkey shorter than 32 bytes must be a hard error rather than a
        // silent fall-through that would leave the key buffer all-zero and
        // lead to encryption under a known zero key.
        let short = [1u8; 16];
        let vault = SecureMemoryVault::new(&short).unwrap();
        assert!(matches!(
            extract_enc_key(&vault),
            Err(VaultError::CryptoError(_))
        ));
    }

    #[test]
    fn extract_enc_key_rejects_empty_subkey() {
        let vault = SecureMemoryVault::new(&[]).unwrap();
        assert!(matches!(
            extract_enc_key(&vault),
            Err(VaultError::CryptoError(_))
        ));
    }

    #[test]
    fn create_and_export_open_roundtrip() {
        let mut vault = Vault::create(b"test-password-123").unwrap();
        let exported = vault.export().unwrap();
        let _reopened = Vault::open(b"test-password-123", &exported).unwrap();
    }

    #[test]
    fn store_and_retrieve_roundtrip() {
        let mut vault = Vault::create(b"test-password").unwrap();
        vault.store("api_key", b"sk-secret-12345").unwrap();

        let exported = vault.export().unwrap();
        let reopened = Vault::open(b"test-password", &exported).unwrap();
        assert_eq!(
            reopened.retrieve("api_key").unwrap(),
            Some(b"sk-secret-12345".to_vec())
        );
    }

    #[test]
    fn retrieve_missing_returns_none() {
        let vault = Vault::create(b"password").unwrap();
        assert_eq!(vault.retrieve("nonexistent").unwrap(), None);
    }

    #[test]
    fn remove_returns_true_for_existing() {
        let mut vault = Vault::create(b"password").unwrap();
        vault.store("key", b"value").unwrap();
        assert!(vault.remove("key").unwrap());
        assert_eq!(vault.retrieve("key").unwrap(), None);
    }

    #[test]
    fn remove_returns_false_for_missing() {
        let mut vault = Vault::create(b"password").unwrap();
        assert!(!vault.remove("nonexistent").unwrap());
    }

    #[test]
    fn wrong_password_fails_open() {
        let mut vault = Vault::create(b"correct-pw").unwrap();
        let exported = vault.export().unwrap();
        assert!(matches!(
            Vault::open(b"wrong-pw!", &exported),
            Err(VaultError::InvalidPassword)
        ));
    }

    #[test]
    fn tampered_export_fails_open() {
        let mut vault = Vault::create(b"password").unwrap();
        let mut exported = vault.export().unwrap();
        if let Some(last) = exported.last_mut() {
            *last ^= 0xFF;
        }
        assert!(Vault::open(b"password", &exported).is_err());
    }

    #[test]
    fn multiple_entries() {
        let mut vault = Vault::create(b"password").unwrap();
        vault.store("key1", b"value1").unwrap();
        vault.store("key2", b"value2").unwrap();
        vault.store("key3", b"value3").unwrap();

        let exported = vault.export().unwrap();
        let reopened = Vault::open(b"password", &exported).unwrap();

        assert_eq!(reopened.retrieve("key1").unwrap(), Some(b"value1".to_vec()));
        assert_eq!(reopened.retrieve("key2").unwrap(), Some(b"value2".to_vec()));
        assert_eq!(reopened.retrieve("key3").unwrap(), Some(b"value3".to_vec()));
    }

    #[test]
    fn empty_vault_roundtrip() {
        let mut vault = Vault::create(b"password").unwrap();
        let exported = vault.export().unwrap();
        let _reopened = Vault::open(b"password", &exported).unwrap();
    }

    #[test]
    fn store_empty_data() {
        let mut vault = Vault::create(b"password").unwrap();
        vault.store("empty", b"").unwrap();

        let exported = vault.export().unwrap();
        let reopened = Vault::open(b"password", &exported).unwrap();
        assert_eq!(reopened.retrieve("empty").unwrap(), Some(vec![]));
    }

    #[test]
    fn save_and_load_roundtrip() {
        let dir = std::env::temp_dir();
        let path = dir.join("memseal_test_save_load2.seal");

        let mut vault = Vault::create(b"file-password").unwrap();
        vault.store("secret", b"file-stored-value").unwrap();
        vault.save(&path).unwrap();

        let loaded = Vault::load(&path, b"file-password").unwrap();
        assert_eq!(
            loaded.retrieve("secret").unwrap(),
            Some(b"file-stored-value".to_vec())
        );

        std::fs::remove_file(&path).ok();
    }

    #[test]
    fn load_wrong_password_fails() {
        let dir = std::env::temp_dir();
        let path = dir.join("memseal_test_wrong_pw2.seal");

        let mut vault = Vault::create(b"correct!").unwrap();
        vault.save(&path).unwrap();

        assert!(matches!(
            Vault::load(&path, b"wrong!!!"),
            Err(VaultError::InvalidPassword)
        ));

        std::fs::remove_file(&path).ok();
    }

    #[test]
    fn change_password_then_retrieve_by_name() {
        let mut vault = Vault::create(b"old-password").unwrap();
        vault.store("key", b"preserved-value").unwrap();

        vault
            .change_password(b"old-password", b"new-password")
            .unwrap();

        let mut exported = vault.export().unwrap();
        assert!(Vault::open(b"old-password", &exported).is_err());

        let reopened = Vault::open(b"new-password", &exported).unwrap();
        assert_eq!(
            reopened.retrieve("key").unwrap(),
            Some(b"preserved-value".to_vec())
        );
        exported.zeroize();
    }

    #[test]
    fn change_password_wrong_current_fails() {
        let mut vault = Vault::create(b"real-pwd!").unwrap();
        assert!(vault.change_password(b"wrong!!!", b"new-pwd!").is_err());
    }

    #[test]
    fn export_rotates_nonce() {
        let mut vault = Vault::create(b"password").unwrap();
        let nonce_before = vault.index.nonce;
        let _ = vault.export().unwrap();
        assert_ne!(vault.index.nonce, nonce_before);
    }

    #[test]
    fn multiple_exports_produce_different_ciphertext() {
        let mut vault = Vault::create(b"password").unwrap();
        vault.store("k", b"v").unwrap();
        let e1 = vault.export().unwrap();
        let e2 = vault.export().unwrap();
        assert_ne!(e1, e2);
    }

    #[test]
    fn header_validation_rejects_extreme_memory() {
        let bad = VaultHeader::new([0; 16], 4, MAX_KDF_MEMORY + 1, 32);
        assert!(validate_header(&bad).is_err());
    }

    #[test]
    fn header_validation_rejects_zero_iterations() {
        let bad = VaultHeader::new([0; 16], 0, 131_072, 32);
        assert!(validate_header(&bad).is_err());
    }

    #[test]
    fn header_validation_rejects_wrong_version() {
        let mut h = VaultHeader::new([0; 16], 4, 131_072, 32);
        h.version = 99;
        assert!(validate_header(&h).is_err());
    }

    #[test]
    fn header_validation_rejects_wrong_key_length() {
        let bad = VaultHeader::new([0; 16], 4, 131_072, 16);
        assert!(validate_header(&bad).is_err());
    }

    #[test]
    fn header_validation_rejects_below_minimum_memory() {
        let bad = VaultHeader::new([0; 16], 4, MIN_KDF_MEMORY - 1, 32);
        assert!(validate_header(&bad).is_err());
    }

    #[test]
    fn short_password_rejected() {
        assert!(Vault::create(b"short").is_err());
    }

    #[test]
    fn long_entry_name_rejected() {
        let mut vault = Vault::create(b"password").unwrap();
        let long_name = "x".repeat(MAX_ENTRY_NAME_LEN + 1);
        assert!(vault.store(&long_name, b"data").is_err());
    }

    #[test]
    fn header_len_overflow_rejected() {
        let mut data = vec![0u8; 100];
        data[..4].copy_from_slice(&u32::MAX.to_le_bytes());
        assert!(Vault::open(b"password", &data).is_err());
    }

    // Tampering group A: raw-bytes truncation and boundary checks.
    // These test the bounded parsing logic of `Vault::open` independent of
    // header content or ciphertext validity.

    fn make_valid_export(password: &[u8]) -> Vec<u8> {
        let mut vault = Vault::create(password).unwrap();
        vault.store("k", b"v").unwrap();
        vault.export().unwrap()
    }

    #[test]
    fn open_rejects_empty_input() {
        let result = Vault::open(b"password", &[]);
        assert!(matches!(result, Err(VaultError::CorruptedData(_))));
    }

    #[test]
    fn open_rejects_input_below_header_len_field() {
        let result = Vault::open(b"password", &[0u8, 0, 0]);
        assert!(matches!(result, Err(VaultError::CorruptedData(_))));
    }

    #[test]
    fn open_rejects_zero_header_len() {
        // 4 bytes containing header_len=0, plus enough trailing bytes to clear
        // the size check. Header JSON region is empty so deserialization fails.
        let mut data = vec![0u8; 4 + XCHACHA20_NONCE_LEN + 8];
        data[..4].copy_from_slice(&0u32.to_le_bytes());
        let result = Vault::open(b"password", &data);
        assert!(matches!(result, Err(VaultError::CorruptedData(_))));
    }

    #[test]
    fn open_rejects_header_len_exceeding_available_bytes() {
        // header_len within MAX_VAULT_FILE_SIZE but larger than provided bytes.
        // Exercises the `data.len() < min_total` branch, distinct from the
        // `header_len > MAX_VAULT_FILE_SIZE` branch covered by
        // `header_len_overflow_rejected`.
        let mut data = vec![0u8; 64];
        data[..4].copy_from_slice(&10_000u32.to_le_bytes());
        let result = Vault::open(b"password", &data);
        assert!(matches!(result, Err(VaultError::CorruptedData(_))));
    }

    #[test]
    fn open_rejects_truncation_inside_nonce_region() {
        // Truncate a valid export inside the 24-byte nonce region.
        let valid = make_valid_export(b"password-aaaa");
        let header_len = u32::from_le_bytes(valid[..4].try_into().unwrap()) as usize;
        let nonce_start = 4 + header_len;
        // Keep half of the nonce so the size check fails.
        let truncated = &valid[..nonce_start + XCHACHA20_NONCE_LEN / 2];
        let result = Vault::open(b"password-aaaa", truncated);
        assert!(matches!(result, Err(VaultError::CorruptedData(_))));
    }

    #[test]
    fn open_rejects_truncation_inside_counter_region() {
        // Truncate a valid export inside the 8-byte counter region.
        let valid = make_valid_export(b"password-bbbb");
        let header_len = u32::from_le_bytes(valid[..4].try_into().unwrap()) as usize;
        let counter_start = 4 + header_len + XCHACHA20_NONCE_LEN;
        // Keep half of the counter so the size check fails.
        let truncated = &valid[..counter_start + 4];
        let result = Vault::open(b"password-bbbb", truncated);
        assert!(matches!(result, Err(VaultError::CorruptedData(_))));
    }

    #[test]
    fn open_rejects_missing_ciphertext_after_counter() {
        // Header + nonce + counter present, but the ciphertext region is empty
        // (no AEAD tag). The size check passes; AEAD must reject the input.
        // `open_with_aad` failures are surfaced as `InvalidPassword` by design.
        let valid = make_valid_export(b"password-cccc");
        let header_len = u32::from_le_bytes(valid[..4].try_into().unwrap()) as usize;
        let cut = 4 + header_len + XCHACHA20_NONCE_LEN + 8;
        let truncated = &valid[..cut];
        let result = Vault::open(b"password-cccc", truncated);
        assert!(matches!(result, Err(VaultError::InvalidPassword)));
    }

    // Tampering group B: header JSON tampering and AAD divergence.
    // The vault header is serialized as JSON and used as AAD for index
    // encryption. Any modification must either be rejected by parsing/
    // validation or surface as an AEAD failure (`InvalidPassword`).

    fn replace_header_json(export: &[u8], new_header_json: &[u8]) -> Vec<u8> {
        let old_header_len = u32::from_le_bytes(export[..4].try_into().unwrap()) as usize;
        let after_old_header = 4 + old_header_len;
        let mut out =
            Vec::with_capacity(4 + new_header_json.len() + export.len() - after_old_header);
        out.extend_from_slice(&(new_header_json.len() as u32).to_le_bytes());
        out.extend_from_slice(new_header_json);
        out.extend_from_slice(&export[after_old_header..]);
        out
    }

    fn extract_header_value(export: &[u8]) -> serde_json::Value {
        let header_len = u32::from_le_bytes(export[..4].try_into().unwrap()) as usize;
        serde_json::from_slice(&export[4..4 + header_len]).unwrap()
    }

    #[test]
    fn open_rejects_malformed_header_json() {
        let valid = make_valid_export(b"password-dddd");
        let tampered = replace_header_json(&valid, b"{");
        let result = Vault::open(b"password-dddd", &tampered);
        assert!(matches!(result, Err(VaultError::CorruptedData(_))));
    }

    #[test]
    fn open_rejects_header_missing_required_field() {
        let valid = make_valid_export(b"password-eeee");
        let mut value = extract_header_value(&valid);
        // Drop the `version` field; deserialization must fail because
        // VaultHeader fields have no serde defaults.
        value.as_object_mut().unwrap().remove("version");
        let new_json = serde_json::to_vec(&value).unwrap();
        let tampered = replace_header_json(&valid, &new_json);
        let result = Vault::open(b"password-eeee", &tampered);
        assert!(matches!(result, Err(VaultError::CorruptedData(_))));
    }

    #[test]
    fn open_rejects_header_with_unsupported_version() {
        let valid = make_valid_export(b"password-ffff");
        let mut value = extract_header_value(&valid);
        value.as_object_mut().unwrap().insert(
            "version".to_string(),
            serde_json::Value::Number(999u16.into()),
        );
        let new_json = serde_json::to_vec(&value).unwrap();
        let tampered = replace_header_json(&valid, &new_json);
        let result = Vault::open(b"password-ffff", &tampered);
        assert!(matches!(result, Err(VaultError::CorruptedData(_))));
    }

    #[test]
    fn open_rejects_header_with_out_of_range_kdf_memory() {
        // Exercises validate_header() through the full open() path, distinct
        // from the unit test that calls validate_header() in isolation.
        let valid = make_valid_export(b"password-gggg");
        let mut value = extract_header_value(&valid);
        value.as_object_mut().unwrap().insert(
            "kdf_memory_cost".to_string(),
            serde_json::Value::Number((MAX_KDF_MEMORY + 1).into()),
        );
        let new_json = serde_json::to_vec(&value).unwrap();
        let tampered = replace_header_json(&valid, &new_json);
        let result = Vault::open(b"password-gggg", &tampered);
        assert!(matches!(result, Err(VaultError::CorruptedData(_))));
    }

    #[test]
    fn open_rejects_header_with_modified_salt() {
        // Salt is part of the header AAD AND drives master key derivation.
        // Both effects independently force AEAD to fail; surfaced as
        // InvalidPassword.
        let valid = make_valid_export(b"password-hhhh");
        let mut value = extract_header_value(&valid);
        let salt = value
            .as_object_mut()
            .unwrap()
            .get_mut("kdf_salt")
            .unwrap()
            .as_array_mut()
            .unwrap();
        // Flip a byte in the salt.
        let first = salt[0].as_u64().unwrap();
        salt[0] = serde_json::Value::Number((first ^ 0xFF & 0xFF).into());
        let new_json = serde_json::to_vec(&value).unwrap();
        let tampered = replace_header_json(&valid, &new_json);
        let result = Vault::open(b"password-hhhh", &tampered);
        assert!(matches!(result, Err(VaultError::InvalidPassword)));
    }

    #[test]
    fn open_rejects_cross_vault_header_swap() {
        // Two vaults created with the same password produce different headers
        // (different random salts). Swapping the header of vault A onto the
        // ciphertext of vault B must fail: the header bytes are bound to the
        // index ciphertext as AAD, and the salt drives master key derivation.
        let password: &[u8] = b"shared-password";
        let export_a = make_valid_export(password);
        let export_b = make_valid_export(password);

        let header_len_a = u32::from_le_bytes(export_a[..4].try_into().unwrap()) as usize;
        let header_a_json = &export_a[4..4 + header_len_a];

        let tampered = replace_header_json(&export_b, header_a_json);
        let result = Vault::open(password, &tampered);
        assert!(matches!(result, Err(VaultError::InvalidPassword)));
    }

    // Tampering group C: nonce and counter region tampering.
    // The nonce is part of the AEAD construction; tampering must surface as
    // an AEAD failure. The counter field stored in the file at the fixed
    // offset is an export of vault state but is not consumed by `open()`
    // (the authoritative counter values are inside the encrypted index JSON).

    #[test]
    fn open_rejects_bit_flipped_nonce() {
        let password: &[u8] = b"password-iiii";
        let valid = make_valid_export(password);
        let header_len = u32::from_le_bytes(valid[..4].try_into().unwrap()) as usize;
        let nonce_start = 4 + header_len;

        let mut tampered = valid.clone();
        // Flip the first byte of the 24-byte nonce.
        tampered[nonce_start] ^= 0x01;

        let result = Vault::open(password, &tampered);
        assert!(matches!(result, Err(VaultError::InvalidPassword)));
    }

    #[test]
    fn open_ignores_counter_field_in_file() {
        // The 8-byte counter field written by `export()` at offset
        // `4 + header_len + 24` is never read back by `open()`; the
        // authoritative counter is recovered from the encrypted index JSON.
        // This test pins that behavior: flipping every bit of those 8 bytes
        // must NOT change the outcome of `open()`. If this test ever starts
        // failing, the file format is being used differently and the layout
        // should be revisited (the 8 bytes are otherwise wire-format dead
        // weight that should either be removed or actually consumed).
        let password: &[u8] = b"password-jjjj";
        let valid = make_valid_export(password);
        let header_len = u32::from_le_bytes(valid[..4].try_into().unwrap()) as usize;
        let counter_start = 4 + header_len + XCHACHA20_NONCE_LEN;

        let mut tampered = valid.clone();
        for i in 0..8 {
            tampered[counter_start + i] ^= 0xFF;
        }

        let from_valid = Vault::open(password, &valid).unwrap();
        let from_tampered = Vault::open(password, &tampered).unwrap();
        // Both opens succeed and recover the same authoritative counter.
        assert_eq!(
            from_valid.index.nonce_counter,
            from_tampered.index.nonce_counter
        );
    }

    #[test]
    fn open_rejects_replaying_old_nonce_into_new_export() {
        // Each `export()` rotates the nonce. Splicing the nonce from an
        // earlier export onto a later export's ciphertext must fail because
        // the ciphertext was sealed with the later nonce; the AAD also
        // authenticates the header but the nonce is what the AEAD uses to
        // decrypt.
        let password: &[u8] = b"password-kkkk";
        let mut vault = Vault::create(password).unwrap();
        vault.store("k", b"v").unwrap();

        let export_first = vault.export().unwrap();
        let export_second = vault.export().unwrap();

        let header_len_first = u32::from_le_bytes(export_first[..4].try_into().unwrap()) as usize;
        let header_len_second = u32::from_le_bytes(export_second[..4].try_into().unwrap()) as usize;
        // The two exports share the same header (same KDF params and salt),
        // so the nonce offset is the same in both.
        assert_eq!(header_len_first, header_len_second);

        let nonce_start = 4 + header_len_second;
        let old_nonce =
            &export_first[4 + header_len_first..4 + header_len_first + XCHACHA20_NONCE_LEN];

        let mut tampered = export_second.clone();
        tampered[nonce_start..nonce_start + XCHACHA20_NONCE_LEN].copy_from_slice(old_nonce);

        // Sanity: the spliced nonce really differs from the second export's nonce.
        assert_ne!(
            &export_second[nonce_start..nonce_start + XCHACHA20_NONCE_LEN],
            old_nonce
        );

        let result = Vault::open(password, &tampered);
        assert!(matches!(result, Err(VaultError::InvalidPassword)));
    }

    // Tampering group D: ciphertext region tampering.
    // The ciphertext is XChaCha20-Poly1305 sealed: any modification to
    // either the encrypted payload or the 16-byte authentication tag must
    // cause AEAD verification to fail.

    const POLY1305_TAG_LEN: usize = 16;

    fn ciphertext_offset(export: &[u8]) -> usize {
        let header_len = u32::from_le_bytes(export[..4].try_into().unwrap()) as usize;
        4 + header_len + XCHACHA20_NONCE_LEN + 8
    }

    #[test]
    fn open_rejects_bit_flip_in_ciphertext_body() {
        // Flip a bit at the start of the AEAD ciphertext (before the trailing
        // tag). This targets the encrypted payload, not the tag.
        let password: &[u8] = b"password-llll";
        let valid = make_valid_export(password);
        let ct_start = ciphertext_offset(&valid);
        // Sanity: there must be at least one byte of body before the 16-byte tag.
        assert!(valid.len() > ct_start + POLY1305_TAG_LEN);

        let mut tampered = valid.clone();
        tampered[ct_start] ^= 0x01;

        let result = Vault::open(password, &tampered);
        assert!(matches!(result, Err(VaultError::InvalidPassword)));
    }

    #[test]
    fn open_rejects_bit_flip_in_poly1305_tag() {
        // Flip a bit in the middle of the 16-byte Poly1305 tag. Distinct from
        // the existing `tampered_export_fails_open` test which flips the very
        // last byte; here we hit a non-edge byte of the tag region.
        let password: &[u8] = b"password-mmmm";
        let valid = make_valid_export(password);
        let tag_mid = valid.len() - POLY1305_TAG_LEN + (POLY1305_TAG_LEN / 2);

        let mut tampered = valid.clone();
        tampered[tag_mid] ^= 0x40;

        let result = Vault::open(password, &tampered);
        assert!(matches!(result, Err(VaultError::InvalidPassword)));
    }

    #[test]
    fn open_rejects_ciphertext_with_tag_stripped() {
        // Remove the trailing 16-byte Poly1305 tag entirely. AEAD must reject.
        let password: &[u8] = b"password-nnnn";
        let valid = make_valid_export(password);
        let truncated = &valid[..valid.len() - POLY1305_TAG_LEN];

        let result = Vault::open(password, truncated);
        assert!(matches!(result, Err(VaultError::InvalidPassword)));
    }

    #[test]
    fn open_rejects_ciphertext_with_trailing_garbage_appended() {
        // Append junk after the tag. Without the original tag positioned at
        // end-of-input, AEAD verification reads the wrong bytes as the tag
        // and fails.
        let password: &[u8] = b"password-oooo";
        let valid = make_valid_export(password);

        let mut tampered = valid.clone();
        tampered.extend_from_slice(&[0xAB, 0xCD, 0xEF]);

        let result = Vault::open(password, &tampered);
        assert!(matches!(result, Err(VaultError::InvalidPassword)));
    }

    // Tampering group E: index entry-count cap.
    // `open()` must reject an index whose decoded `files` map exceeds
    // MAX_INDEX_ENTRIES, even though the bytes are validly sealed under the
    // correct key. This guards against a crafted index forcing a larger
    // in-memory map than the format allows.

    /// Forges a vault export whose encrypted index contains `entry_count`
    /// entries, sealed correctly under the key derived from `password`.
    fn forge_export_with_entries(password: &[u8], entry_count: usize) -> Vec<u8> {
        use crate::vault::vault_index::derive_subkeys;

        let header = VaultHeader::generate().unwrap();
        let mut master_key = derive_master_key(password, &header).unwrap();
        let (mut enc_sub, _hmac_sub) = derive_subkeys(&master_key, &header.kdf_salt).unwrap();
        master_key.zeroize();

        let mut files = std::collections::HashMap::new();
        for i in 0..entry_count {
            files.insert(
                format!("{:064x}", i),
                IndexMetaBlockMetadata::new(
                    IndexMetaBlockLocation::Inline,
                    0,
                    0,
                    true,
                    None,
                    None,
                    0,
                ),
            );
        }

        // Any unique nonce works: open() reads the nonce from the file and
        // never re-derives it.
        let mut nonce = [0u8; XCHACHA20_NONCE_LEN];
        secure_bytes_fill(&mut nonce).unwrap();

        // All forged entries use data_counter 0, so data_nonce_counter must be
        // at least 1 to satisfy the nonce-counter invariant enforced on open.
        let index_json = serde_json::json!({
            "version": crate::constants::vault_index_constants::VAULT_INDEX_VERSION,
            "nonce": nonce.to_vec(),
            "nonce_counter": 1u64,
            "data_nonce_counter": 1u64,
            "files": files,
        });
        let index_bytes = serde_json::to_vec(&index_json).unwrap();

        let aad = header.to_aad_bytes().unwrap();
        let encrypted_index = seal_with_aad(&enc_sub, &nonce, &index_bytes, &aad).unwrap();
        enc_sub.zeroize();

        let header_json = serde_json::to_vec(&header).unwrap();
        let mut output = Vec::new();
        output.extend_from_slice(&(header_json.len() as u32).to_le_bytes());
        output.extend_from_slice(&header_json);
        output.extend_from_slice(&nonce);
        output.extend_from_slice(&1u64.to_le_bytes());
        output.extend_from_slice(&encrypted_index);
        output
    }

    #[test]
    fn open_accepts_index_at_entry_cap() {
        let password: &[u8] = b"password-cap-ok";
        let max = crate::constants::vault_index_constants::MAX_INDEX_ENTRIES;
        let data = forge_export_with_entries(password, max);
        // Sealed correctly and at the cap: open must succeed.
        assert!(Vault::open(password, &data).is_ok());
    }

    #[test]
    fn open_rejects_index_over_entry_cap() {
        let password: &[u8] = b"password-cap-no";
        let max = crate::constants::vault_index_constants::MAX_INDEX_ENTRIES;
        let data = forge_export_with_entries(password, max + 1);
        // One past the cap: open must reject as corrupted, not build the map.
        assert!(matches!(
            Vault::open(password, &data),
            Err(VaultError::CorruptedData(_))
        ));
    }

    // Group F: export size bound and nonce freshness.

    #[test]
    fn export_size_validation_boundary() {
        assert!(validate_export_size(MAX_VAULT_FILE_SIZE).is_ok());
        assert!(matches!(
            validate_export_size(MAX_VAULT_FILE_SIZE + 1),
            Err(VaultError::SerializationError(_))
        ));
    }

    /// Slowest test in the suite (tens of seconds in debug): it materializes
    /// an export just above the 256 MiB cap to pin the guarantee end to end.
    /// Encrypted bytes serialize into the index JSON as number arrays at
    /// roughly 3.6 output bytes per stored byte, so 74 MiB of plaintext is
    /// enough to cross the cap.
    #[test]
    fn export_rejects_vault_exceeding_max_file_size() {
        fn pseudo_bytes(len: usize, seed: u64) -> Vec<u8> {
            (0..len)
                .map(|i| ((i as u64 ^ seed).wrapping_mul(2654435761) >> 7) as u8)
                .collect()
        }

        let mut vault = Vault::create(b"password-size-cap").unwrap();
        vault
            .store("a", &pseudo_bytes(37 * 1024 * 1024, 1))
            .unwrap();
        vault
            .store("b", &pseudo_bytes(37 * 1024 * 1024, 2))
            .unwrap();

        assert!(matches!(
            vault.export(),
            Err(VaultError::SerializationError(_))
        ));

        // The vault stays intact and usable: dropping one entry brings the
        // export back under the cap.
        assert!(vault.remove("b").unwrap());
        let exported = vault.export().unwrap();
        assert!(exported.len() as u64 <= MAX_VAULT_FILE_SIZE);
        let reopened = Vault::open(b"password-size-cap", &exported).unwrap();
        assert!(reopened.retrieve("a").unwrap().is_some());
    }

    #[test]
    fn storing_same_name_twice_uses_fresh_nonces() {
        // Entry nonces are random per store(), never a function of the
        // counter alone; re-storing a name must produce new nonce prefixes.
        let mut vault = Vault::create(b"password-nonces").unwrap();
        vault.store("k", b"v1").unwrap();
        let first: Vec<Vec<u8>> = vault
            .index
            .files
            .values()
            .map(|m| m.encrypted_data.as_ref().unwrap()[..XCHACHA20_NONCE_LEN].to_vec())
            .collect();
        vault.store("k", b"v1").unwrap();
        let second: Vec<Vec<u8>> = vault
            .index
            .files
            .values()
            .map(|m| m.encrypted_data.as_ref().unwrap()[..XCHACHA20_NONCE_LEN].to_vec())
            .collect();
        assert_eq!(first.len(), 1);
        assert_eq!(second.len(), 1);
        assert_ne!(first[0], second[0]);
    }

    // Group G: change_password handling of dummy and inconsistent entries.

    #[test]
    fn change_password_drops_dummies_and_keeps_real_entries() {
        let mut vault = Vault::create(b"old-password").unwrap();
        vault.store("real", b"payload").unwrap();
        vault.index.files.insert(
            format!("{:064x}", 0),
            IndexMetaBlockMetadata::new(IndexMetaBlockLocation::Inline, 0, 0, true, None, None, 0),
        );

        vault
            .change_password(b"old-password", b"new-password")
            .unwrap();

        assert_eq!(vault.retrieve("real").unwrap(), Some(b"payload".to_vec()));
        // Only the re-encrypted real entry survives.
        assert_eq!(vault.index.files.len(), 1);
    }

    #[test]
    fn change_password_rejects_real_entry_missing_ciphertext() {
        let mut vault = Vault::create(b"old-password").unwrap();
        vault.store("real", b"payload").unwrap();
        // Non-dummy entry without ciphertext fields: the index is
        // inconsistent and the password change must fail, not silently drop.
        vault.index.files.insert(
            format!("{:064x}", 1),
            IndexMetaBlockMetadata::new(IndexMetaBlockLocation::Inline, 0, 0, false, None, None, 0),
        );

        assert!(matches!(
            vault.change_password(b"old-password", b"new-password"),
            Err(VaultError::CorruptedData(_))
        ));
        // The failed change leaves the original vault usable.
        assert_eq!(vault.retrieve("real").unwrap(), Some(b"payload".to_vec()));
    }
}