qualia2fa-core 0.1.0

Qualia2FA core library: TOTP/HOTP primitives, encrypted vault and device sync
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
// Copyright (C) 2026 Qualia2FA contributors
//
// This library is free software; you can redistribute it and/or modify it under
// the terms of the GNU Lesser General Public License as published by the Free
// Software Foundation; either version 3 of the License, or (at your option) any
// later version.
//
// This library is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
// details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with this library; if not, see <https://www.gnu.org/licenses/>.

//! Encryption and decryption of a single record: turning vault entries and tombstones into records
//! inside a payload, and back.
//!
//! # It **shares one encoding** with the vault file
//!
//! The plaintext encoding of entries and tombstones is reused wholesale from [`crate::vault`] (the
//! codecs of [`crate::vault::Entry`] and [`crate::vault::Tombstone`]). This is deliberate:
//!
//! - **Two hand-written encodings drift.** Adding a field to the file format while forgetting the
//!   sync format shows up as "the new field is empty after syncing" — silent data loss that fails
//!   no test.
//! - **Syncing is moving the same data to another device**, with identical semantics on both
//!   sides, so there is no reason for two representations.
//!
//! **Domain separation comes from keys, not from encoding**: sync records use a subkey different
//! from the vault's ([`crate::sync::derive_record_key`]), so even if the two AADs were identical
//! byte for byte, the ciphertexts remain mutually undecryptable. See
//! [`crate::sync::HKDF_SALT_SYNC`].
//!
//! # The kind is inside the ciphertext, so decryption tries twice
//!
//! A record's **kind** (live entry or tombstone) is part of the AAD, and the AAD must be known
//! before decryption — so keeping the kind out of the plaintext creates a chicken-and-egg problem.
//! Putting the kind into the plaintext under a key that does not depend on it would instead make
//! "move an entry's ciphertext into a tombstone's slot" invisible to the AEAD, wasting the
//! per-record authentication.
//!
//! The resolution is to try both kinds: `SyncEntry` first, then `SyncTombstone`, and take the one
//! that authenticates. This introduces **no ambiguity** — the two kinds have different AADs, so one
//! ciphertext cannot pass both authentications (probability on the order of 2^-128). The cost is
//! one extra Poly1305 computation on a failed attempt, and failure only happens in two situations:
//! a wrong key (the very first record of the payload fails) or a tampered payload (the whole sync
//! is void). On the normal path each kind is tried once.
//!
//! What it buys is that an attacker cannot see how many deletions this sync carries. That
//! information has limited value in itself, but it is **free** here — and it can only be got right
//! once: once a kind byte is written into the plaintext, taking it back requires a new payload
//! format version.

use crate::sync::{FORMAT_VERSION, InnerRecordKind, Record};
use crate::vault::crypto;
use crate::vault::{Entry as VaultEntry, EntryId, Tombstone};
use crate::{Error, Result};

/// Length of the entry plaintext header: magic 4 + id 16 + length 2.
const ENTRY_HEADER_LEN: usize = 4 + EntryId::LEN + 2;

/// Magic number of the entry plaintext header.
///
/// It fails the first check when the **encoding layer is misused** (an entry encoding fed to the
/// tombstone decoder, or a future record type dispatched to the wrong branch), instead of leaving
/// the parser to report a "field tag not recognized" error for a byte stream with completely
/// different semantics. On the normal path the AAD already binds kind + entry ID + version, so any
/// plaintext that arrives does match.
const ENTRY_MAGIC: &[u8; 4] = b"Q2FE";

/// A record that has been decrypted and parsed.
///
/// Kept separate from the wire type: [`crate::sync::Record`] is bytes plus an identifier, while
/// `OpenedRecord` is the semantics — an entry or a tombstone that can be merged.
#[derive(Debug, PartialEq, Eq)]
pub enum OpenedRecord {
    /// A live entry.
    Entry {
        /// Identifier of the entry. It is also the identifier of this record in the payload
        /// (checked to be equal).
        id: EntryId,
        /// The entry content.
        entry: Box<VaultEntry>,
    },
    /// A tombstone.
    Tombstone(Box<Tombstone>),
}

impl OpenedRecord {
    /// Which entry this record is about.
    #[must_use]
    pub fn entry_id(&self) -> EntryId {
        match self {
            Self::Entry { id, .. } => *id,
            Self::Tombstone(tombstone) => tombstone.entry_id,
        }
    }

    /// Converts this into a form that can be handed to [`crate::sync::merge_into`].
    #[must_use]
    pub fn into_merged(self) -> crate::sync::MergedRecord {
        match self {
            Self::Entry { id, entry } => crate::sync::MergedRecord::Entry { id, entry },
            Self::Tombstone(tombstone) => crate::sync::MergedRecord::Tombstone(tombstone),
        }
    }
}

/// Encrypts an entry into a record of the payload.
///
/// `id` is the identifier of this entry in the set. The entry itself carries no identifier (see
/// [`crate::vault::Entry`]), so this function writes it into the plaintext header and it is checked
/// again on decryption — see item 2 of [`open`].
///
/// # Errors
///
/// - [`Error::SyncMalformed`]: the entry encoding exceeds 65535 bytes. Real entries are far smaller
///   (the longest field is capped at 64 KiB), so this only happens when the encoding logic is
///   wrong.
/// - [`Error::RandomnessUnavailable`]: the random source is unavailable.
pub fn seal_entry(key: &[u8; 32], id: &EntryId, entry: &VaultEntry) -> Result<Record> {
    let body = crate::vault::encode_entry(entry)?;
    let mut plaintext = Vec::with_capacity(ENTRY_HEADER_LEN + body.len());
    plaintext.extend_from_slice(ENTRY_MAGIC);
    plaintext.extend_from_slice(id.as_bytes());
    let len = u16::try_from(body.len()).map_err(|_| Error::SyncMalformed {
        reason: "单条条目的编码超过 65535 字节",
    })?;
    plaintext.extend_from_slice(&len.to_be_bytes());
    plaintext.extend_from_slice(&body);

    seal_plaintext(key, InnerRecordKind::SyncEntry, id, &plaintext)
}

/// Encrypts a tombstone into a record of the payload.
///
/// # Errors
///
/// [`Error::RandomnessUnavailable`]: the random source is unavailable.
pub fn seal_tombstone(key: &[u8; 32], tombstone: &Tombstone) -> Result<Record> {
    let plaintext = crate::vault::encode_tombstone(tombstone);
    seal_plaintext(
        key,
        InnerRecordKind::SyncTombstone,
        &tombstone.entry_id,
        &plaintext,
    )
}

/// Seals a plaintext into a record with an already-derived subkey.
///
/// The common tail of [seal_entry] and [seal_tombstone]: the two differ only in the plaintext
/// encoding and the kind byte.
fn seal_plaintext(
    key: &[u8; 32],
    kind: InnerRecordKind,
    id: &EntryId,
    plaintext: &[u8],
) -> Result<Record> {
    let (nonce, ciphertext) = crypto::seal_with_key(key, kind, id, FORMAT_VERSION, plaintext)?;
    Ok(Record {
        entry_id: *id,
        nonce,
        ciphertext,
    })
}

/// Decrypts and parses a record.
///
/// # What it checks
///
/// 1. **The AAD**: kind, entry ID, payload format version. Moving entry A's ciphertext into entry
///    B's slot therefore fails authentication because the ID in the AAD does not match, instead of
///    being silently accepted as B's content.
/// 2. **The identifier in the plaintext header equals `record.entry_id`**. This is **not**
///    redundant: the AAD uses `record.entry_id`, and a caller may hold a different identifier
///    obtained elsewhere when looking up the merge result. Carrying a copy inside the plaintext and
///    checking it closes the gap where "the record claims to be A while the content came from B".
/// 3. **The magic number and length in the entry plaintext header**: the header starts with `Q2FE`,
///    followed by the identifier and a u16 length. Those 4 bytes are not redundant — the AAD
///    already binds kind and identifier, but when the **encoding layer is misused** (an entry
///    encoding fed to the tombstone decoder), the first check catches it instead of leaving the
///    parser to struggle through a byte stream with completely different semantics and finally
///    report a "field tag not recognized" error pointing in the wrong direction.
///
/// Both kinds are tried — `SyncEntry` first, then `SyncTombstone` — and the one that authenticates
/// is taken; see the module documentation.
///
/// # Errors
///
/// - [`Error::SyncAuthenticationFailed`]: both kinds failed authentication. It **does not
///   distinguish** a wrong key, tampered ciphertext and an AAD mismatch — telling them apart helps
///   an attacker and has no actionable value for the user. Why **not**
///   [`Error::VaultDecryptionFailed`]: that one means "the vault file cannot be opened", and the
///   client handles it (send the user to check the master key) quite differently from "this sync
///   payload is untrustworthy".
/// - [`Error::SyncMalformed`]: the plaintext structure contradicts itself. The outer layer and the
///   AAD have both authenticated, so reaching this point means **our own encoding logic is wrong**,
///   not that we are under attack.
/// - The [`Error::VaultUnknownRecordKind`] family: the internal structure of the entry plaintext is
///   invalid. This **can** genuinely come from a legitimate payload (an encoding written by an
///   older version and read by a newer one), so the specific diagnosis is kept instead of folding
///   it into `SyncMalformed`.
pub fn open(key: &[u8; 32], record: &Record) -> Result<OpenedRecord> {
    let mut last_error = None;
    for kind in [InnerRecordKind::SyncEntry, InnerRecordKind::SyncTombstone] {
        match try_open_as(key, kind, record) {
            Ok(opened) => return Ok(opened),
            Err(err) => last_error = Some(err),
        }
    }
    Err(last_error.map_or(Error::SyncAuthenticationFailed, |_| {
        Error::SyncAuthenticationFailed
    }))
}

/// Tries to decrypt a record under a **specified** kind.
///
/// Authentication failure is a **normal** outcome on this path (the kind was guessed wrong), so the
/// caller must continue with the other kind instead of propagating the error.
fn try_open_as(key: &[u8; 32], kind: InnerRecordKind, record: &Record) -> Result<OpenedRecord> {
    let plaintext = crypto::open_with_key(
        key,
        kind,
        &record.entry_id,
        FORMAT_VERSION,
        &record.nonce,
        &record.ciphertext,
    )?;
    match kind {
        InnerRecordKind::SyncEntry => open_entry(&record.entry_id, &plaintext),
        InnerRecordKind::SyncTombstone => open_tombstone(&record.entry_id, &plaintext),
        _ => Err(Error::SyncMalformed {
            reason: "解码路径收到了非同步的记录种类",
        }),
    }
}

/// Parses an **entry** plaintext that has already been decrypted and authenticated.
///
/// The plaintext is already authenticated by the AEAD, so the checks here all target "our own
/// encoding is wrong" rather than an attack.
fn open_entry(id: &EntryId, plaintext: &[u8]) -> Result<OpenedRecord> {
    if plaintext.len() < ENTRY_HEADER_LEN {
        return Err(Error::SyncTruncated);
    }
    if &plaintext[..4] != ENTRY_MAGIC {
        return Err(Error::SyncMalformed {
            reason: "条目明文缺少魔数",
        });
    }
    let embedded: [u8; EntryId::LEN] = plaintext[4..4 + EntryId::LEN]
        .try_into()
        .map_err(|_| Error::SyncTruncated)?;
    if &embedded != id.as_bytes() {
        return Err(Error::SyncMalformed {
            reason: "条目明文里的标识与记录标识不一致",
        });
    }
    let len_bytes: [u8; 2] = plaintext[4 + EntryId::LEN..ENTRY_HEADER_LEN]
        .try_into()
        .map_err(|_| Error::SyncTruncated)?;
    let declared = usize::from(u16::from_be_bytes(len_bytes));
    let body = plaintext
        .get(ENTRY_HEADER_LEN..ENTRY_HEADER_LEN + declared)
        .ok_or(Error::SyncTruncated)?;
    if plaintext.len() != ENTRY_HEADER_LEN + declared {
        return Err(Error::SyncMalformed {
            reason: "条目明文含超出声明长度的内容",
        });
    }
    Ok(OpenedRecord::Entry {
        id: *id,
        entry: Box::new(crate::vault::decode_entry(body)?),
    })
}

/// Parses a **tombstone** plaintext that has already been decrypted and authenticated.
///
/// It reuses the vault-side tombstone decoder directly: the two share the same byte layout, see the
/// module documentation.
fn open_tombstone(id: &EntryId, plaintext: &[u8]) -> Result<OpenedRecord> {
    let tombstone = crate::vault::decode_tombstone(*id, plaintext)?;
    Ok(OpenedRecord::Tombstone(Box::new(tombstone)))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::vault::Timestamp;
    use crate::vault::crypto::RecordKind;

    fn key() -> [u8; 32] {
        [0x5A; 32]
    }

    fn sample_entry() -> VaultEntry {
        VaultEntry::new(
            "ACME".to_owned(),
            Some("ACME 公司".to_owned()),
            "alice@example.com".to_owned(),
            crate::otp::OtpSecret::from_base32("JBSWY3DPEHPK3PXP").expect("种子合法"),
            crate::otp::Algorithm::Sha1,
            crate::otp::Digits::Six,
            30,
            None,
            "工作账号".to_owned(),
            Timestamp::from(1_700_000_000_u64),
            Timestamp::from(1_700_000_100_u64),
        )
        .expect("条目合法")
    }

    #[test]
    fn an_entry_roundtrips() {
        let id = EntryId::from_bytes([1; EntryId::LEN]);
        let entry = sample_entry();
        let record = seal_entry(&key(), &id, &entry).expect("可加密");
        assert_eq!(record.entry_id, id);

        let opened = open(&key(), &record).expect("可解密");
        match opened {
            OpenedRecord::Entry {
                id: got_id,
                entry: got,
            } => {
                assert_eq!(got_id, id);
                assert_eq!(*got, entry, "往返后条目内容不一致");
            }
            other => panic!("期望是条目,实际 {other:?}"),
        }
    }

    #[test]
    fn a_tombstone_roundtrips() {
        let id = EntryId::from_bytes([2; EntryId::LEN]);
        let tombstone = Tombstone {
            entry_id: id,
            deleted_at: 1_700_000_200,
            deleted_by: Some(EntryId::from_bytes([3; EntryId::LEN])),
        };
        let record = seal_tombstone(&key(), &tombstone).expect("可加密");

        let opened = open(&key(), &record).expect("可解密");
        match opened {
            OpenedRecord::Tombstone(got) => assert_eq!(*got, tombstone, "往返后墓碑不一致"),
            other => panic!("期望是墓碑,实际 {other:?}"),
        }
    }

    /// Every `deleted_by` state of a tombstone must round-trip.
    ///
    /// `deleted_by` is an `Option`, and "has a value" versus "has none" differs by a single flag
    /// byte in the encoding, so both states are exercised.
    #[test]
    fn tombstone_deleted_by_states_roundtrip() {
        let id = EntryId::from_bytes([4; EntryId::LEN]);
        for deleted_by in [None, Some(EntryId::from_bytes([5; EntryId::LEN]))] {
            let tombstone = Tombstone {
                entry_id: id,
                deleted_at: 1,
                deleted_by,
            };
            let record = seal_tombstone(&key(), &tombstone).expect("可加密");
            let opened = open(&key(), &record).expect("可解密");
            match opened {
                OpenedRecord::Tombstone(got) => assert_eq!(got.deleted_by, deleted_by),
                other => panic!("期望是墓碑,实际 {other:?}"),
            }
        }
    }

    /// **A wrong key must fail authentication, and must not be misread as the other kind.**
    #[test]
    fn a_wrong_key_is_rejected() {
        let id = EntryId::from_bytes([6; EntryId::LEN]);
        let record = seal_entry(&key(), &id, &sample_entry()).expect("可加密");
        let err = open(&[0x5B; 32], &record);
        assert!(
            matches!(err, Err(Error::SyncAuthenticationFailed)),
            "实际 {err:?}"
        );
    }

    /// **Moving entry A's ciphertext into entry B's slot must fail.**
    ///
    /// This is the entire reason the per-record inner authentication exists: without it, a
    /// **legitimate** payload could make a receiver permanently store a record whose content and
    /// identifier do not match.
    #[test]
    fn moving_a_ciphertext_to_another_id_is_rejected() {
        let id_a = EntryId::from_bytes([7; EntryId::LEN]);
        let id_b = EntryId::from_bytes([8; EntryId::LEN]);
        let mut record = seal_entry(&key(), &id_a, &sample_entry()).expect("可加密");

        record.entry_id = id_b;
        let err = open(&key(), &record);
        assert!(
            matches!(err, Err(Error::SyncAuthenticationFailed)),
            "条目密文被搬到另一个 ID 后仍被接受,实际 {err:?}"
        );
    }

    /// A tombstone's ciphertext must fail when it is read as an entry.
    ///
    /// This also verifies that trying both kinds introduces no **misjudgement**: a tombstone
    /// ciphertext must not authenticate on the entry path, and the normal path must yield a
    /// tombstone.
    #[test]
    fn a_tombstone_cannot_be_read_as_an_entry() {
        let id = EntryId::from_bytes([9; EntryId::LEN]);
        let tombstone = Tombstone {
            entry_id: id,
            deleted_at: 1_700_000_300,
            deleted_by: None,
        };
        let record = seal_tombstone(&key(), &tombstone).expect("可加密");
        match open(&key(), &record).expect("可解密") {
            OpenedRecord::Tombstone(_) => {}
            other => panic!("墓碑被误判成 {other:?}"),
        }
    }

    /// Flipping a single ciphertext byte must be rejected, at **every** byte position.
    #[test]
    fn flipping_any_ciphertext_byte_is_rejected() {
        let id = EntryId::from_bytes([10; EntryId::LEN]);
        let record = seal_entry(&key(), &id, &sample_entry()).expect("可加密");
        for index in 0..record.ciphertext.len() {
            let mut tampered = record.clone();
            tampered.ciphertext[index] ^= 0x01;
            let err = open(&key(), &tampered);
            assert!(
                matches!(err, Err(Error::SyncAuthenticationFailed)),
                "第 {index} 字节被翻转后仍被接受,实际 {err:?}"
            );
        }
    }

    /// Tampering with the nonce must be rejected.
    ///
    /// Tested separately because the nonce is not part of the ciphertext, so the
    /// ciphertext-flipping test does not cover it.
    #[test]
    fn a_tampered_nonce_is_rejected() {
        let id = EntryId::from_bytes([11; EntryId::LEN]);
        let record = seal_entry(&key(), &id, &sample_entry()).expect("可加密");
        for index in 0..record.nonce.len() {
            let mut tampered = record.clone();
            tampered.nonce[index] ^= 0x80;
            let err = open(&key(), &tampered);
            assert!(
                matches!(err, Err(Error::SyncAuthenticationFailed)),
                "nonce 第 {index} 字节被翻转后仍被接受,实际 {err:?}"
            );
        }
    }

    /// A truncated ciphertext must be rejected.
    #[test]
    fn a_truncated_ciphertext_is_rejected() {
        let id = EntryId::from_bytes([12; EntryId::LEN]);
        let record = seal_entry(&key(), &id, &sample_entry()).expect("可加密");
        for cut in 1..record.ciphertext.len().min(8) {
            let mut short = record.clone();
            short.ciphertext.truncate(record.ciphertext.len() - cut);
            assert!(open(&key(), &short).is_err(), "截断 {cut} 字节后仍被接受");
        }
    }

    /// Every record must use a different nonce.
    ///
    /// A 192-bit random nonce does not collide at real-world scale; the property matters because a
    /// constant nonce would only become visible as catastrophic keystream reuse once both sides
    /// hold enough data.
    #[test]
    fn nonces_differ_across_records() {
        let id = EntryId::from_bytes([13; EntryId::LEN]);
        let entry = sample_entry();
        let first = seal_entry(&key(), &id, &entry).expect("可加密");
        let second = seal_entry(&key(), &id, &entry).expect("可加密");
        assert_ne!(first.nonce, second.nonce, "两次加密用了同一个 nonce");
        assert_ne!(
            first.ciphertext, second.ciphertext,
            "同一内容两次加密产生了相同密文,随机性有问题"
        );
    }

    /// **The sync record key and the vault record key must be two different keys.**
    ///
    /// This is the core domain-separation assertion. If the two were equal, a sync payload's
    /// ciphertext could be fed in as a vault file record (the AADs might be identical byte for
    /// byte) and the AEAD would authenticate it.
    #[test]
    fn sync_and_vault_keys_are_different() {
        let master = crate::vault::MasterKey::generate().expect("随机源可用");

        let sync_key = crate::sync::derive_record_key(&master).expect("可派生");
        let vault_key = crypto::entry_key(&master).expect("可派生");

        assert_ne!(
            sync_key, vault_key,
            "同步记录与保险库记录派生了同一把子密钥,域分离失效"
        );

        let envelope_key = crate::sync::derive_envelope_key(&master).expect("可派生");
        assert_ne!(sync_key, envelope_key, "记录密钥与信封密钥相同");
        assert_ne!(vault_key, envelope_key, "保险库密钥与信封密钥相同");

        let again = crate::sync::derive_record_key(&master).expect("可派生");
        assert_eq!(sync_key, again, "同一主密钥两次派生的结果不同");
    }

    /// The constants themselves must not change silently.
    ///
    /// They are an external contract: changing them would make every sync payload already sent out
    /// (including the ones inside QR codes) undecryptable.
    #[test]
    fn the_domain_separation_constants_are_stable() {
        assert_eq!(
            crate::sync::HKDF_SALT_SYNC,
            *b"Qualia2FA sync salt v1 (records)",
            "同步的 HKDF salt 变了,既有载荷会全部解不开"
        );
        assert_eq!(
            RecordKind::SyncEntry.as_byte(),
            0x81,
            "同步条目的种类字节变了"
        );
        assert_eq!(
            RecordKind::SyncTombstone.as_byte(),
            0x82,
            "同步墓碑的种类字节变了"
        );
        assert_eq!(FORMAT_VERSION, 2, "载荷格式版本变了");
    }

    /// **The record kind bytes of the two domains must not overlap, and each parser must refuse the
    /// other's kinds.**
    ///
    /// These are the second line of defence for domain separation (the first is the HKDF salt). It
    /// is a test rather than only a compile-time assertion in `sync.rs` because "the file parser
    /// does not accept sync kinds" is a **behavioural** assertion that a compile-time assertion
    /// cannot express: the file kinds are `0x01` / `0x02` and the sync kinds are `0x81` / `0x82`.
    #[test]
    fn the_two_domains_kinds_do_not_overlap() {
        assert!(RecordKind::from_byte(0x01).is_some());
        assert!(RecordKind::from_byte(0x02).is_some());
        assert!(
            RecordKind::from_byte(0x81).is_none(),
            "文件解析器接受了同步条目种类——一份同步载荷会被当成保险库文件收下"
        );
        assert!(
            RecordKind::from_byte(0x82).is_none(),
            "文件解析器接受了同步墓碑种类"
        );

        assert!(RecordKind::from_sync_byte(0x81).is_some());
        assert!(RecordKind::from_sync_byte(0x82).is_some());
        assert!(
            RecordKind::from_sync_byte(0x01).is_none(),
            "同步解析器接受了文件条目种类"
        );
        assert!(RecordKind::from_sync_byte(0x02).is_none());
        assert!(RecordKind::from_sync_byte(0x00).is_none());
        assert!(RecordKind::from_sync_byte(0xFF).is_none());
    }

    /// The encoded length of a record is "fixed header + ciphertext", used by higher layers to
    /// estimate payload size.
    ///
    /// The fixed header is a 16-byte identifier, a 24-byte nonce and a 4-byte length; the ciphertext
    /// already includes the authentication tag, and there is **no** kind byte on the wire — the kind
    /// lives in the inner AAD, not in the plaintext.
    ///
    /// # This test covers the `record` side only
    ///
    /// It asserts that a record's own length equals the constant plus its own ciphertext length,
    /// which holds by construction and cannot catch a wrong constant: a single extra byte inside
    /// `RECORD_HEADER_LEN` would leave it passing. What actually pins the wire format down is
    /// `the_plaintext_record_framing_matches_encoded_len` in `sync`, which unwraps a sealed payload,
    /// reads the real plaintext byte by byte and reconciles it with `encoded_len`. This one is kept
    /// to cover the lower bound that `encoded_len` does account for the nonce and the ciphertext.
    #[test]
    fn encoded_len_matches_the_wire_format() {
        let id = EntryId::from_bytes([14; EntryId::LEN]);
        let record = seal_entry(&key(), &id, &sample_entry()).expect("可加密");
        assert_eq!(record.encoded_len(), 16 + 24 + 4 + record.ciphertext.len());
    }

    /// Entry identifiers survive the round trip byte for byte, including boundary patterns such as
    /// all zeros or all `0xFF`, which an encoding is most likely to mistake for "absent".
    #[test]
    fn entry_ids_survive_the_roundtrip_byte_for_byte() {
        for pattern in [0x00_u8, 0xFF, 0x80] {
            for position in 0..EntryId::LEN {
                let mut bytes = [0_u8; EntryId::LEN];
                bytes[position] = pattern;
                let id = EntryId::from_bytes(bytes);
                let record = seal_entry(&key(), &id, &sample_entry()).expect("可加密");
                match open(&key(), &record).expect("可解密") {
                    OpenedRecord::Entry { id: got, .. } => {
                        assert_eq!(got.as_bytes(), &bytes, "标识 {bytes:?} 往返后不同");
                    }
                    other => panic!("期望是条目,实际 {other:?}"),
                }
            }
        }
    }

    #[test]
    fn timestamp_boundaries_survive_the_roundtrip() {
        let id = EntryId::from_bytes([15; EntryId::LEN]);
        for deleted_at in [1_u64, u32::MAX.into(), u64::MAX] {
            let tombstone = Tombstone {
                entry_id: id,
                deleted_at: Timestamp::from(deleted_at),
                deleted_by: None,
            };
            let record = seal_tombstone(&key(), &tombstone).expect("可加密");
            match open(&key(), &record).expect("可解密") {
                OpenedRecord::Tombstone(got) => assert_eq!(
                    got.deleted_at, deleted_at,
                    "删除时间 {deleted_at} 往返后不同"
                ),
                other => panic!("期望是墓碑,实际 {other:?}"),
            }
        }
    }
}