Skip to main content

qualia2fa_core/
sync.rs

1// Copyright (C) 2026 Qualia2FA contributors
2//
3// This library is free software; you can redistribute it and/or modify it under
4// the terms of the GNU Lesser General Public License as published by the Free
5// Software Foundation; either version 3 of the License, or (at your option) any
6// later version.
7//
8// This library is distributed in the hope that it will be useful, but WITHOUT
9// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10// FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
11// details.
12//
13// You should have received a copy of the GNU Lesser General Public License
14// along with this library; if not, see <https://www.gnu.org/licenses/>.
15
16//! Envelope for sync payloads: serialize → compress → whole-payload AEAD.
17//!
18//! # Responsibility
19//!
20//! Turn "one device's entries and tombstones" into a byte string that can be carried over
21//! any channel, and back again. It does not know whether a payload travels by QR code, over
22//! a LAN, or by some other means, nor who the peer is: this layer is transport-agnostic and
23//! depends on no transport module.
24//!
25//! # Three layers, and what each of them stops
26//!
27//! ```text
28//! ┌─ outer envelope (plaintext header + whole-payload AEAD) ─────────┐
29//! │  magic 8 | version 1 | kind 1 | sync_id 32 | device ID 32        │
30//! │  nonce 24 | ciphertext (including authentication tag)            │
31//! │  ┌─ inner: compression ──────────────────────────────────┐       │
32//! │  │  ┌─ payload plaintext ───────────────────────────┐    │       │
33//! │  │  │ record count 4 | record × N                   │    │       │
34//! │  │  │   each: entry_id 16 | nonce 24 | len 4 | ct   │    │       │
35//! │  │  └───────────────────────────────────────────────┘    │       │
36//! │  └───────────────────────────────────────────────────────┘       │
37//! └──────────────────────────────────────────────────────────────────┘
38//! ```
39//!
40//! The remainder of the plaintext header (magic, version, kind, `sync_id`, device ID)
41//! serves as the outer AAD as a whole, so on the wire those fields appear exactly once.
42//!
43//! - **Inner per-record AEAD** (kind + `entry_id` + format version in the AAD): stops
44//!   moving the ciphertext of entry A into the slot of entry B, and stops presenting a
45//!   tombstone as an entry. Its key is **not** the record key of the vault file (the HKDF
46//!   salt differs), so ciphertexts of the two domains cannot be opened across domains. See
47//!   [`derive_record_key`]. Merging accepts records one at a time and stores them verbatim,
48//!   so this per-record binding is what keeps a stored record matching its identifier.
49//! - **Outer whole-payload AEAD**: stops splicing together parts of two different
50//!   payloads. Per-record authentication cannot detect such a splice, because every record
51//!   is valid on its own. The outer AAD covers the entire plaintext header, so moving any
52//!   of those fields makes the outer authentication fail.
53//! - **Compression** takes part in no security property; it only makes the bytes shorter.
54//!
55//! # The plaintext carries no record kind
56//!
57//! Whether a record is a live entry or a tombstone is written only in the **inner AAD**,
58//! not in the payload plaintext. The cost is that opening a record tries both kinds (see
59//! [`record`]); the benefit is that not even the number of deletions in a sync is visible,
60//! and the existence of a deletion is itself sensitive information. `RECORD_HEADER_LEN`
61//! therefore counts no kind byte.
62//!
63//! # Replay
64//!
65//! Every payload carries a 32-byte random `sync_id`. Merging is idempotent (merging the
66//! same data twice yields the same result), so a replay does not break correctness; it does
67//! waste a full decryption and merge, and it lets an attacker move the "last sync time"
68//! shown in the UI to any value. [`SeenSyncIds`] therefore records the identifiers that
69//! were processed, and rejects repeats outright.
70
71mod adjudicate;
72pub mod compress;
73mod merge;
74pub mod record;
75mod seen;
76
77use crate::vault::crypto;
78use crate::vault::{EntryId, MasterKey, NONCE_LEN, TAG_LEN};
79use crate::{Error, Result};
80pub use adjudicate::{Adjudicated, Decision, adjudicate};
81pub use merge::{
82    Conflict, ConflictKind, HasEntryId, MergeOutcome, MergedRecord, RecordSet, merge_into,
83};
84use qualia_dik::{DEVICE_ID_LEN, DeviceId};
85pub use record::{OpenedRecord, open as open_record, seal_entry, seal_tombstone};
86pub use seen::{MAX_SEEN_SYNC_IDS, SeenSyncIds};
87
88/// Payload magic. 8 ASCII bytes, chosen to be recognizable in a hex editor.
89pub const MAGIC: &[u8; 8] = b"Q2FASYNC";
90
91/// Payload format version currently written.
92///
93/// **Fully independent** of [`crate::vault::FORMAT_VERSION`]: that one governs the layout of
94/// vault files, this one the layout of payloads. The two evolve separately; binding "the
95/// payload format changed" to "the file format changed" would make any change in one of them
96/// require a statement from the other.
97///
98/// # Version 1 → 2: the payload kind joined the outer AAD
99///
100/// The version 1 AAD was only `magic || version || sync_id || sender`, so a payload asking
101/// the peer to send its vault and a payload saying "this is my full state" were
102/// **indistinguishable in the outer layer**. With an empty record set both even had exactly
103/// the same byte structure, leaving the receiver to guess from convention — and the wrong
104/// guess ("an empty full state" read as a request) makes both sides wait for the other to
105/// start.
106///
107/// Version 2 states this explicitly in [`PayloadKind`] and binds it into the AAD: the kind is
108/// therefore **authenticated** and a third party cannot change it.
109///
110/// # Version 2 → 3: entries may carry fields the older format could not express
111///
112/// Version 3 payloads transport entries whose plaintext may contain node tags an older
113/// version does not know: the entry kind (TOTP / HOTP / mOTP / Yandex OTP / Steam Guard) and
114/// the two PIN fields. An older peer would skip those nodes by their length and store a
115/// **mis-typed entry** — an mOTP entry silently becoming TOTP, with a code that never
116/// works — so mixing versions is refused rather than tolerated.
117///
118/// Nothing else about the payload layout changed: the only difference is the number, which
119/// takes part in both the outer and the inner AAD.
120///
121/// # Version 3 → 4: the group tree, the icon, and u32 length prefixes
122///
123/// Three shapes changed at once, and all three are why a version 3 peer must refuse the payload
124/// rather than skip what it does not know:
125///
126/// - A group plaintext now carries one ordered child list (entries and child groups mixed)
127///   instead of a flat position and an entry list. An older peer would find neither of the nodes
128///   it needs.
129/// - An entry plaintext may carry an icon node. An older peer would skip it by its length, keep
130///   the entry without its picture, and write that loss back into its own vault - a silent one,
131///   which is the worst kind.
132/// - The length prefix of both plaintext headers is now a **u32**. An icon of up to 64 KiB, or a
133///   group whose child list approaches 64 KiB, does not fit a u16: it would be refused as
134///   malformed on both sides of a payload that is otherwise fine. The prefix width is part of the
135///   version for the same reason as the rest: a version 3 record would be read with the wrong
136///   width.
137///
138/// # It also takes part in the **inner** record AAD
139///
140/// Changing it makes the inner records of existing payloads unopenable. That is what stops
141/// cross-version transplanting: an inner record of an older version must not be moved into a
142/// newer payload.
143pub const FORMAT_VERSION: u8 = 4;
144
145/// Payload version number used only by version 1.
146///
147/// **No version 1 payload has ever existed**: that format was never released. The constant
148/// has exactly one purpose — giving "version 1 input must be rejected explicitly" a name to
149/// refer to, so a test can pin it down. No path reconstructing the version 1 AAD is provided
150/// on purpose: the version 1 and version 2 AADs differ in length, so `open` would grow a
151/// branch selecting the AAD length from the input version, and no real input could ever
152/// reach it.
153///
154/// This does not contradict "only grows" in [`crate::vault::READABLE_VERSIONS`]: that rule
155/// covers **user files** (a user holds old files and must keep being able to open them).
156/// Payloads are transient — they never touch disk and need not survive across versions.
157///
158/// Only tests use it, so the constant is `#[cfg(test)]`. A `pub(crate)` constant does **not**
159/// trigger `dead_code` in a library build, so `expect(dead_code, ...)` would be an
160/// unfulfilled expectation and `-D warnings` rejects it.
161#[cfg(test)]
162pub(crate) const LEGACY_FORMAT_VERSION_1: u8 = 1;
163
164/// What a payload **asks the peer to do**.
165///
166/// Separate from the `vault` record kind, which describes one record **inside** a payload
167/// (entry or tombstone); this one describes the payload as a whole.
168///
169/// It takes part in the outer AAD and is therefore authenticated. The kind decides whether
170/// the receiver **sends a payload back**, and an unauthenticated one could be changed by a
171/// third party into a request that makes the receiver send its entire vault.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum PayloadKind {
174    /// The sender's **full state**: accept it and merge.
175    Vault,
176    /// The sender's **request**: send me your full state.
177    ///
178    /// A request carries **no** records: "what I want" is a **set**, and in real use that set
179    /// is "everything". The receiver answers with [`Payload::from_vault`]. Incremental sync
180    /// will add a list of wanted IDs here rather than introduce a different format — the same
181    /// convention described on [`Payload::from_vault`].
182    ///
183    /// It is a distinct variant rather than "an empty payload means a request": a freshly
184    /// registered device with no entries yet sends a full sync whose payload is **also**
185    /// empty.
186    Request,
187}
188
189impl PayloadKind {
190    /// Byte value on the wire.
191    #[must_use]
192    pub const fn as_byte(self) -> u8 {
193        match self {
194            Self::Vault => 0x01,
195            Self::Request => 0x02,
196        }
197    }
198
199    /// Restore from a wire byte.
200    #[must_use]
201    pub const fn from_byte(value: u8) -> Option<Self> {
202        match value {
203            0x01 => Some(Self::Vault),
204            0x02 => Some(Self::Request),
205            _ => None,
206        }
207    }
208}
209
210/// Length of the payload plaintext header: magic + version + kind + sync_id + sender.
211///
212/// It holds no field beyond "who the sender is" — the sender identifier is in the header
213/// because it is part of the outer AAD (authenticated but not secret, see
214/// [`Payload::sender`]).
215///
216/// The field is a [`DeviceId`] (32 bytes, the SHA-256 of the device identity public key),
217/// not the 16-byte [`EntryId`]: `EntryId` identifies **a record in the vault** and is random,
218/// while [`DeviceId`] identifies **a device** and is what "who is talking to me" resolves to.
219pub const HEADER_LEN: usize = MAGIC.len() + 1 + 1 + SYNC_ID_LEN + DEVICE_ID_LEN;
220
221/// Length of a payload identifier.
222///
223/// 256-bit, as wide as an entry ID: it needs collision resistance, not resistance to
224/// guessing (knowing someone else's payload identifier is of no use — the payload itself is
225/// protected by AEAD).
226pub const SYNC_ID_LEN: usize = 32;
227
228/// Identifier of one sync.
229///
230/// Generated randomly by the initiator and used by the **receiver** to recognize duplicate
231/// payloads. It is not a secret.
232pub type SyncId = [u8; SYNC_ID_LEN];
233
234/// Length of the fixed part of one record in the payload plaintext: `entry_id` + nonce +
235/// ciphertext length.
236///
237/// The record kind byte is part of the inner AAD, but **not** of the plaintext, so not even
238/// the number of deletions in a sync is visible; opening a record tries both kinds (see the
239/// module documentation of [`record`]).
240///
241/// The parser does not depend on this constant — it reads records by their length prefix.
242/// [`Record::encoded_len`] does, so the constant must count exactly the bytes the framing
243/// actually writes.
244const RECORD_HEADER_LEN: usize = EntryId::LEN + NONCE_LEN + 4;
245
246/// Maximum number of records allowed in a payload.
247///
248/// Shares its origin with [`crate::vault::MAX_RECORDS`] for vault files but is a separate
249/// constant: the two are **different** limits (a file is local storage, a payload arrives
250/// over the network). Defining them separately means relaxing one does not relax the other.
251pub const MAX_RECORDS: usize = 1_000_000;
252
253/// Length limit of the payload plaintext (**before** compression).
254///
255/// The record count limit together with actual record lengths decides this value; the
256/// decompression layer has a limit of its own ([`compress::MAX_DECOMPRESSED_LEN`]) and both
257/// must pass. This one bounds payloads **we generate**, rejecting obviously unreasonable
258/// data sets while they are being built.
259pub const MAX_PLAINTEXT_LEN: usize = 64 * 1024 * 1024;
260
261/// HKDF salt used to derive the **sync record** subkey from the master key.
262///
263/// The two domains are separated at the **key level** by this salt, not by the AAD bytes: the
264/// record kind byte and the format version field can hold the same values in both domains, so
265/// a compile-time assertion at the end of `sync.rs` checks that the kind bytes do not overlap.
266///
267/// The vault entry key uses an all-zero salt; changing that derivation would make every
268/// existing `.q2fa` file unopenable.
269///
270/// # The value of this constant is an external contract
271///
272/// It is 32 ASCII bytes, and changing one character makes every existing sync payload
273/// (including those already sent inside QR codes) unopenable. Writing it as a literal
274/// 32-byte array rather than a string constant plus an assertion lets the type check of
275/// `[u8; 32]` catch a wrong length at **compile time**.
276pub const HKDF_SALT_SYNC: [u8; 32] = *b"Qualia2FA sync salt v1 (records)";
277
278/// Derive the **sync record** subkey from the master key.
279///
280/// It is **not** the vault file entry subkey; see [`HKDF_SALT_SYNC`].
281///
282/// It is derived once per sync and then passed to every record: a payload can hold tens of
283/// thousands of records, and deriving again per record would multiply two HMAC-SHA256
284/// computations by the number of entries.
285pub fn derive_record_key(master: &MasterKey) -> Result<[u8; 32]> {
286    crypto::derive_key_with_salt(master, &HKDF_SALT_SYNC, crypto::HKDF_INFO_SYNC_RECORD)
287}
288
289/// Derive the **outer envelope** subkey from the master key.
290///
291/// Independent of the sync record subkey, of the vault subkeys, and of the check-value
292/// derivation. Outer and inner use different keys on purpose: the outer layer encrypts bytes
293/// that are **already encrypted per record**, and if the two layers shared one key the inner
294/// ciphertext would become part of the outer plaintext — using one key to encrypt both
295/// plaintext and ciphertext is a textbook misuse.
296pub fn derive_envelope_key(master: &MasterKey) -> Result<[u8; 32]> {
297    crypto::derive_key_with_salt(master, &HKDF_SALT_SYNC, crypto::HKDF_INFO_SYNC_ENVELOPE)
298}
299
300/// One record in a payload: a per-record ciphertext together with the cleartext information
301/// that locates it.
302///
303/// # A record does not know whether it is an entry or a tombstone
304///
305/// The kind lives in the ciphertext (it is part of the AAD but **not** of the record
306/// header), so not even the number of deletions in a sync is visible. The cost is that
307/// opening a record tries both kinds; see the module documentation of [`record`] for the
308/// trade-off.
309///
310/// It carries **ciphertext only**, never decrypted content, so `Clone` does not widen the
311/// exposure of key material.
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct Record {
314    /// Which entry this record belongs to.
315    pub entry_id: EntryId,
316    /// Random nonce used to encrypt this record.
317    pub nonce: [u8; NONCE_LEN],
318    /// Ciphertext, **including** the 16-byte authentication tag.
319    pub ciphertext: Vec<u8>,
320}
321
322impl merge::HasEntryId for Record {
323    /// Which entry this record is about.
324    ///
325    /// `entry_id` also takes part in the inner AAD, so it is authenticated: an attacker
326    /// cannot change a record's identifier to another value and still open it.
327    fn entry_id(&self) -> EntryId {
328        self.entry_id
329    }
330}
331
332impl Record {
333    /// Number of bytes this record occupies in the payload plaintext.
334    #[must_use]
335    pub fn encoded_len(&self) -> usize {
336        RECORD_HEADER_LEN + self.ciphertext.len()
337    }
338}
339
340/// The content of a payload: every record to be sent this time.
341///
342/// Records are held in a [`RecordSet`], keyed by [`EntryId`]: union merging treats the same
343/// entry appearing twice in one payload as meaningless, so the set type carries the "at most
344/// one record per entry" invariant and a duplicate is reported as a structural error while
345/// parsing.
346///
347/// # Record order is deterministic
348///
349/// [`RecordSet`] is a `BTreeMap` internally and iterates in ascending [`EntryId`] order, so
350/// **the same content serializes to a byte-identical plaintext**. That is not the same as
351/// "byte-identical payload": every record's nonce is random, so the ciphertexts differ.
352/// Determinism is useful for exact byte comparisons in tests and gives a cheap criterion for
353/// "do two payloads carry the same data".
354#[derive(Debug)]
355pub struct Payload {
356    /// Identifier of this sync. The receiver uses it to recognize duplicate payloads.
357    pub sync_id: SyncId,
358    /// The device that started this sync.
359    ///
360    /// Takes part in the **outer** AAD, so it is authenticated: an attacker cannot change a
361    /// payload's sender to somebody else. Merging needs to know who is initiating, because
362    /// conflict resolution is decided by the initiator.
363    ///
364    /// # It is a device identifier, but does **not** take part in device authentication
365    ///
366    /// The "sender" here is only an authenticated **claim**: it proves that the party
367    /// holding the master key claims to be this device. Actually proving the device identity
368    /// (private key in hardware) is the job of [`crate::auth`], which is a different path.
369    /// Neither replaces the other: this field stops a payload from being attributed to
370    /// somebody else, which device authentication does not.
371    pub sender: DeviceId,
372    /// The records in this payload, **still encrypted**.
373    pub records: RecordSet<Record>,
374    /// What this payload asks the peer to do.
375    ///
376    /// Private field: it must **always** match the byte written to the wire format, so only
377    /// the constructors set it and a read-only accessor exposes it. Making it public would
378    /// allow changing the field without re-running `seal`.
379    kind: PayloadKind,
380}
381
382impl Payload {
383    /// Generate a fresh payload identifier.
384    ///
385    /// # Errors
386    ///
387    /// [`Error::RandomnessUnavailable`]: the OS random source is unavailable. There must be
388    /// **no** fallback to a userspace PRNG: a predictable payload identifier defeats replay
389    /// protection, because an attacker could precompute an identifier that will be accepted
390    /// later.
391    pub fn new_sync_id() -> Result<SyncId> {
392        let mut id = [0_u8; SYNC_ID_LEN];
393        crate::getrandom::fill(&mut id).map_err(|_| Error::RandomnessUnavailable)?;
394        Ok(id)
395    }
396
397    /// What this payload asks the peer to do.
398    #[must_use]
399    pub const fn kind(&self) -> PayloadKind {
400        self.kind
401    }
402
403    /// Build a **request**: send me your full state.
404    ///
405    /// A request is a payload like any other: the same transport, the same authentication, and
406    /// the same replay protection.
407    ///
408    /// A request carries **no** records (see [`PayloadKind::Request`]), so it is the
409    /// fixed-length header plus a nonce and the authentication tag — [`HEADER_LEN`] +
410    /// [`NONCE_LEN`] + [`TAG_LEN`] = 114 bytes plus the compressed plaintext — far smaller
411    /// than any real full state.
412    ///
413    /// **"Small" is relative; it does not mean "always fits the smallest QR code".** The
414    /// request carries the 32-byte device identifier and the 32-byte payload identifier plus
415    /// the authentication tag, so it takes up a substantial part of the capacity of any QR
416    /// code; the smallest versions cannot hold it. Whether it fits is answered by the
417    /// **channel**: [`crate::transport::TransportCapabilities::check_fits`] can tell before
418    /// sending.
419    ///
420    /// # Errors
421    ///
422    /// [`Error::RandomnessUnavailable`]: the random source is unavailable.
423    pub fn request(sender: DeviceId) -> Result<Self> {
424        Ok(Self {
425            sync_id: Self::new_sync_id()?,
426            sender,
427            records: RecordSet::<Record>::new(),
428            kind: PayloadKind::Request,
429        })
430    }
431
432    /// Build a payload from the local vault.
433    ///
434    /// It seals **all** live entries and **all** tombstones. Incremental sync will be a
435    /// filter parameter on this function rather than a different payload format.
436    ///
437    /// # When an entry and its tombstone coexist, **only** the tombstone is sent
438    ///
439    /// A vault can hold a live entry together with its tombstone — the state in which
440    /// adjudication has not finished ([`crate::vault::Vault::record_tombstone`] never deletes
441    /// the live entry). In that state the live entry must **not** be sent too: the peer would
442    /// read it as "this one is not deleted yet", so every sync would trigger the same
443    /// conflict on the peer and the user would face the same adjudication screen again on
444    /// every sync.
445    ///
446    /// A tombstone represents a deletion that has **already happened**, while that live entry
447    /// is local state awaiting adjudication. Broadcasting state awaiting adjudication would
448    /// make every device take a decision the user has not taken yet.
449    ///
450    /// # The record key is derived once
451    ///
452    /// A payload can hold tens of thousands of records, and deriving HKDF again per record
453    /// would multiply two HMAC-SHA256 computations by the number of entries. Deriving once
454    /// and passing that key to every record reduces the cost from O(n) derivations to one.
455    ///
456    /// # Errors
457    ///
458    /// - [`Error::SyncTooManyRecords`]: the sum of entries and tombstones exceeds
459    ///   [`MAX_RECORDS`].
460    /// - [`Error::RandomnessUnavailable`]: the random source is unavailable.
461    /// - Sealing failure (see [`record::seal_entry`]).
462    pub fn from_vault(
463        master: &MasterKey,
464        sender: DeviceId,
465        vault: &crate::vault::Vault,
466    ) -> Result<Self> {
467        let record_key = derive_record_key(master)?;
468        let mut records = RecordSet::<Record>::new();
469        for (id, entry) in vault.iter() {
470            if vault.get_tombstone(id).is_some() {
471                continue;
472            }
473            records.insert(record::seal_entry(&record_key, id, entry)?);
474        }
475        for (_, tombstone) in vault.iter_tombstones() {
476            records.insert(record::seal_tombstone(&record_key, tombstone)?);
477        }
478        for (id, group) in vault.groups() {
479            records.insert(record::seal_group(&record_key, id, group)?);
480        }
481        Ok(Self {
482            sync_id: Self::new_sync_id()?,
483            sender,
484            records,
485            kind: PayloadKind::Vault,
486        })
487    }
488
489    /// Read a payload header without decrypting anything.
490    ///
491    /// Replay protection takes effect **before the vault is touched**, and the accounting needs
492    /// the `sync_id`, which lives in the plaintext header. `peek` answers "have I seen this
493    /// identifier" in a single call and allocates nothing large for any input, so a **garbage**
494    /// payload (tampered ciphertext, or plain random bytes) is rejected without a decompression
495    /// attempt and without its claimed identifier having been recorded.
496    ///
497    /// # It authenticates **nothing**
498    ///
499    /// Every returned field comes from the plaintext header, so it is only a **claim**. The
500    /// one legitimate use is looking the `sync_id` up in the replay log: a hit is rejected
501    /// outright (rejecting an unauthenticated payload needs no justification), and a miss
502    /// still has to go through the full [`Payload::open`].
503    ///
504    /// # Errors
505    ///
506    /// The same header checks as [`Payload::open`]: magic, version, length.
507    pub fn peek(data: &[u8]) -> Result<PayloadHeader> {
508        let mut reader = Reader::new(data);
509        let magic = reader.take(MAGIC.len()).ok_or(Error::SyncTruncated)?;
510        if magic != MAGIC {
511            return Err(Error::SyncBadMagic);
512        }
513        let version = reader.u8_bare().ok_or(Error::SyncTruncated)?;
514        check_version(version)?;
515        let kind_byte = reader.u8_bare().ok_or(Error::SyncTruncated)?;
516        let kind = PayloadKind::from_byte(kind_byte)
517            .ok_or(Error::SyncUnknownPayloadKind { value: kind_byte })?;
518        let sync_id: SyncId = reader
519            .take(SYNC_ID_LEN)
520            .ok_or(Error::SyncTruncated)?
521            .try_into()
522            .map_err(|_| Error::SyncTruncated)?;
523        let sender_bytes: [u8; DEVICE_ID_LEN] = reader
524            .take(DEVICE_ID_LEN)
525            .ok_or(Error::SyncTruncated)?
526            .try_into()
527            .map_err(|_| Error::SyncTruncated)?;
528        Ok(PayloadHeader {
529            version,
530            kind,
531            sync_id,
532            sender: DeviceId::from_bytes(sender_bytes),
533        })
534    }
535
536    /// How many records there are (entries plus tombstones).
537    #[must_use]
538    pub fn len(&self) -> usize {
539        self.records.len()
540    }
541
542    /// Whether there is no record at all.
543    ///
544    /// An empty payload is **legal**: that is exactly what a freshly registered device with
545    /// no entries yet sends when it starts a sync. It must be accepted normally (the peer
546    /// learns from it that there is nothing on this side), not treated as an error — that
547    /// would add a branch needing a special case on the "new device joins" path.
548    #[must_use]
549    pub fn is_empty(&self) -> bool {
550        self.records.is_empty()
551    }
552
553    /// Pack the payload into bytes that can be carried around.
554    ///
555    /// The order is **serialize → compress → whole-payload AEAD**.
556    ///
557    /// # Errors
558    ///
559    /// - [`Error::SyncTooManyRecords`]: the number of records exceeds [`MAX_RECORDS`].
560    /// - [`Error::SyncMalformed`]: the packed plaintext exceeds [`MAX_PLAINTEXT_LEN`].
561    /// - [`Error::RandomnessUnavailable`]: the random source is unavailable.
562    pub fn seal(&self, master: &MasterKey) -> Result<Vec<u8>> {
563        if self.records.len() > MAX_RECORDS {
564            return Err(Error::SyncTooManyRecords {
565                count: self.records.len(),
566                limit: MAX_RECORDS,
567            });
568        }
569
570        // ── 1. Serialize ────────────────────────────────────────────────────
571        let mut plaintext = Vec::new();
572        #[expect(
573            clippy::cast_possible_truncation,
574            reason = "already compared against MAX_RECORDS above, which is far below u32::MAX"
575        )]
576        let count = self.records.len() as u32;
577        plaintext.extend_from_slice(&count.to_be_bytes());
578        for record in &self.records {
579            plaintext.extend_from_slice(record.entry_id.as_bytes());
580            plaintext.extend_from_slice(&record.nonce);
581            let len = u32::try_from(record.ciphertext.len()).map_err(|_| Error::SyncMalformed {
582                reason: "a single record ciphertext exceeds 4 GiB",
583            })?;
584            plaintext.extend_from_slice(&len.to_be_bytes());
585            plaintext.extend_from_slice(&record.ciphertext);
586        }
587
588        if plaintext.len() > MAX_PLAINTEXT_LEN {
589            return Err(Error::SyncMalformed {
590                reason: "the payload plaintext exceeds the limit",
591            });
592        }
593
594        // ── 2. Compress ──────────────────────────────────────────────────────
595        let compressed = compress::compress(&plaintext)?;
596
597        // ── 3. Whole-payload AEAD ─────────────────────────────────────────────────
598        let key = derive_envelope_key(master)?;
599        let (nonce, ciphertext) = crypto::seal_bytes(&key, &self.aad(), &compressed)?;
600
601        let mut out = Vec::with_capacity(HEADER_LEN + NONCE_LEN + ciphertext.len());
602        out.extend_from_slice(MAGIC);
603        out.push(FORMAT_VERSION);
604        out.push(self.kind.as_byte());
605        out.extend_from_slice(&self.sync_id);
606        out.extend_from_slice(self.sender.as_bytes());
607        out.extend_from_slice(&nonce);
608        out.extend_from_slice(&ciphertext);
609        Ok(out)
610    }
611
612    /// Take apart bytes carried in from outside, yielding records that are **still
613    /// encrypted**.
614    ///
615    /// # It only handles the outer layer
616    ///
617    /// The returned [`Record`] values are not decrypted yet; that takes a call to
618    /// [`record::open`] per record. Splitting the two steps is deliberate: an outer
619    /// authentication failure and one unopenable record are **different** faults (the whole
620    /// payload is untrustworthy versus the payload is trustworthy but its content is broken),
621    /// and callers handle them differently — the former can only be retried, the latter
622    /// deserves a specific diagnosis.
623    ///
624    /// # Errors
625    ///
626    /// - [`Error::SyncBadMagic`]: not a payload of this application.
627    /// - [`Error::SyncUnsupportedVersion`]: unsupported version.
628    /// - [`Error::SyncTruncated`]: not enough bytes.
629    /// - [`Error::SyncAuthenticationFailed`]: outer authentication failed, with **no**
630    ///   distinction of cause.
631    /// - [`Error::SyncEmptyPayload`] / [`Error::SyncDecompressionFailed`]: compression layer.
632    /// - Other structural errors.
633    ///
634    /// # This function must never panic
635    ///
636    /// The input comes entirely from a channel an attacker controls (any device on the LAN,
637    /// or any byte string). All out-of-range access is stopped by the read cursor: the end
638    /// offset is computed with `checked_add` and then read through `get(range)`, so both
639    /// overflow and out-of-range access return `None` instead of panicking.
640    pub fn open(master: &MasterKey, data: &[u8]) -> Result<Self> {
641        let header = Self::peek(data)?;
642        let PayloadHeader {
643            version,
644            kind,
645            sync_id,
646            sender,
647        } = header;
648
649        let mut reader = Reader::new(data);
650        let _ = reader.take(HEADER_LEN).ok_or(Error::SyncTruncated)?;
651        let nonce: [u8; NONCE_LEN] = reader
652            .take(NONCE_LEN)
653            .ok_or(Error::SyncTruncated)?
654            .try_into()
655            .map_err(|_| Error::SyncTruncated)?;
656        let ciphertext = reader.rest();
657        if ciphertext.len() < TAG_LEN {
658            return Err(Error::SyncTruncated);
659        }
660
661        let aad = header_aad(version, kind, &sync_id, &sender);
662        let key = derive_envelope_key(master)?;
663        let compressed = crypto::open_bytes(&key, &aad, &nonce, ciphertext)
664            .map_err(|_| Error::SyncAuthenticationFailed)?;
665
666        // ── Reverse: decompress → parse ─────────────────────────────────────────────
667        let plaintext = compress::decompress(&compressed)?;
668        let mut reader = Reader::new(&plaintext);
669        let declared = reader.u32_bare().ok_or(Error::SyncTruncated)? as usize;
670
671        if declared > MAX_RECORDS {
672            return Err(Error::SyncTooManyRecords {
673                count: declared,
674                limit: MAX_RECORDS,
675            });
676        }
677
678        let mut records = RecordSet::<Record>::new();
679        for _ in 0..declared {
680            let id_bytes: [u8; EntryId::LEN] = reader
681                .take(EntryId::LEN)
682                .ok_or(Error::SyncTruncated)?
683                .try_into()
684                .map_err(|_| Error::SyncTruncated)?;
685            let nonce: [u8; NONCE_LEN] = reader
686                .take(NONCE_LEN)
687                .ok_or(Error::SyncTruncated)?
688                .try_into()
689                .map_err(|_| Error::SyncTruncated)?;
690            let len = reader.u32_bare().ok_or(Error::SyncTruncated)? as usize;
691            if len < TAG_LEN {
692                return Err(Error::SyncMalformed {
693                    reason: "the record ciphertext is shorter than the authentication tag",
694                });
695            }
696            let ciphertext = reader.take(len).ok_or(Error::SyncTruncated)?.to_vec();
697            if !records.insert(Record {
698                entry_id: EntryId::from_bytes(id_bytes),
699                nonce,
700                ciphertext,
701            }) {
702                return Err(Error::SyncMalformed {
703                    reason: "the payload holds two records for the same entry",
704                });
705            }
706        }
707
708        if !reader.is_empty() {
709            return Err(Error::SyncMalformed {
710                reason: "the payload plaintext holds content beyond the declared record count",
711            });
712        }
713
714        Ok(Self {
715            sync_id,
716            sender,
717            records,
718            kind,
719        })
720    }
721
722    /// Open every record in the payload.
723    ///
724    /// # One failure fails the whole payload
725    ///
726    /// It does not "skip what cannot be opened and take the rest": the outer layer is
727    /// authenticated, so a record that does not open can only mean the producer used the
728    /// wrong key (for instance two devices whose vaults come from different master keys), and
729    /// **partial success** would make such a fault look like "the sync succeeded but a few
730    /// entries are missing" — the hardest kind to diagnose.
731    ///
732    /// # Errors
733    ///
734    /// - [`Error::SyncAuthenticationFailed`]: one record does not open.
735    /// - Other structural errors (see [`record::open`]).
736    pub fn open_records(&self, master: &MasterKey) -> Result<Vec<OpenedRecord>> {
737        let key = derive_record_key(master)?;
738        let mut opened = Vec::with_capacity(self.records.len());
739        for record in &self.records {
740            opened.push(record::open(&key, record)?);
741        }
742        Ok(opened)
743    }
744
745    /// AAD of the outer AEAD.
746    fn aad(&self) -> [u8; AAD_LEN] {
747        header_aad(FORMAT_VERSION, self.kind, &self.sync_id, &self.sender)
748    }
749}
750
751/// The plaintext header of a payload, **unauthenticated**.
752///
753/// Produced by [`Payload::peek`]. Every field comes from the plaintext header, so it is only
754/// a **claim**; the one legitimate use is looking the `sync_id` up in the replay log. See
755/// [`Payload::peek`].
756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757pub struct PayloadHeader {
758    /// Payload format version. Always equal to [`FORMAT_VERSION`] (other versions are
759    /// already rejected inside `peek`).
760    pub version: u8,
761    /// What this payload asks the peer to do.
762    pub kind: PayloadKind,
763    /// Payload identifier, used to consult the replay log.
764    pub sync_id: SyncId,
765    /// The identity the sender **claims**.
766    ///
767    /// As with [`Payload::sender`], it is in the AAD and therefore trustworthy after
768    /// [`Payload::open`]; at the `peek` stage it is still only a claim.
769    pub sender: DeviceId,
770}
771
772/// Check whether a version number is supported.
773///
774/// One implementation shared by [`Payload::peek`] and [`Payload::open`]. A version 1 payload
775/// is rejected here; `sync::tests::a_version_1_payload_is_rejected` pins that down.
776fn check_version(version: u8) -> Result<()> {
777    if version != FORMAT_VERSION {
778        return Err(Error::SyncUnsupportedVersion { version });
779    }
780    Ok(())
781}
782
783/// A bounds-checked read cursor.
784///
785/// It is a **separate copy** of the type of the same name in the vault module: the two read
786/// different formats with different limits, and sharing one would let a change in either
787/// affect the security of the other. The price is a few dozen duplicated lines, far cheaper
788/// than a cursor shared across formats that would have to satisfy both sets of constraints.
789struct Reader<'a> {
790    /// Bytes still to be read.
791    data: &'a [u8],
792    /// Position of the next byte to read.
793    ///
794    /// Invariant: `pos <= data.len()`. Only [`Reader::take`] advances it, and `take` does
795    /// **not** advance it when the range is out of bounds.
796    pos: usize,
797}
798
799impl<'a> Reader<'a> {
800    /// Start reading at the beginning of a byte slice.
801    const fn new(data: &'a [u8]) -> Self {
802        Self { data, pos: 0 }
803    }
804
805    /// Take `len` bytes and advance the cursor; returns `None` and does **not** advance when
806    /// the bytes are not available.
807    ///
808    /// Two guarantees together:
809    ///
810    /// 1. `checked_add` stops integer overflow in `pos + len`. A record field claiming a
811    ///    length of `usize::MAX` could make naive addition wrap around to a small value and
812    ///    slip past the bounds check — a classic parser vulnerability, unreachable here.
813    /// 2. `get(range)` stops out-of-range access.
814    fn take(&mut self, len: usize) -> Option<&'a [u8]> {
815        let end = self.pos.checked_add(len)?;
816        let slice = self.data.get(self.pos..end)?;
817        self.pos = end;
818        Some(slice)
819    }
820
821    /// Read one byte without any semantic check.
822    ///
823    /// The `_bare` suffix means "raw read": the version and kind bytes may hold any value,
824    /// and the caller decides whether they are legal. It is deliberately not called `u8`,
825    /// because integer type names are everywhere in this module and that would read
826    /// ambiguously.
827    fn u8_bare(&mut self) -> Option<u8> {
828        self.take(1).map(|s| s[0])
829    }
830
831    /// Read a **big-endian** 32-bit integer.
832    ///
833    /// Big-endian is network byte order and is how every length field of this format is
834    /// written: little-endian reads backwards in a hex dump.
835    fn u32_bare(&mut self) -> Option<u32> {
836        self.take(4)
837            .and_then(|s| <[u8; 4]>::try_from(s).ok())
838            .map(u32::from_be_bytes)
839    }
840
841    /// Take all remaining bytes and push the cursor to the end.
842    ///
843    /// At the end it returns an empty slice rather than `None`: the outer ciphertext has a
844    /// variable length, so "zero length" is a legitimate result, left for the later "at least
845    /// an authentication tag" check to reject.
846    fn rest(&mut self) -> &'a [u8] {
847        let rest = self.data.get(self.pos..).unwrap_or(&[]);
848        self.pos = self.data.len();
849        rest
850    }
851
852    /// Whether the end has been reached.
853    fn is_empty(&self) -> bool {
854        self.pos >= self.data.len()
855    }
856}
857
858/// Length of the outer AAD: magic + version + kind + sync_id + device ID.
859const AAD_LEN: usize = HEADER_LEN;
860
861/// Build the AAD of the outer AEAD.
862///
863/// # What it authenticates
864///
865/// The entire plaintext header. This is how those fields are bound without being leaked:
866/// `sync_id` and `sender` both have to be authenticated — the former stops swapping a
867/// payload's identifier to bypass replay protection, the latter stops presenting a payload as
868/// somebody else's. `kind` has to be authenticated as well, see [`PayloadKind`].
869///
870/// A free function rather than a method: [`Payload::open`] builds this AAD before any
871/// `Payload` value exists, since authentication comes first and only then are the records
872/// inside known.
873///
874/// # Current version only
875///
876/// Unlike vault files, payloads are **not forward compatible**: a version mismatch is
877/// rejected. Payloads arrive over the network and are transient — they never touch disk.
878/// Being lenient towards an old file on a user's device is mandatory (it is their data),
879/// whereas accepting an old-version **network input** would only add a parsing path to
880/// maintain that never sees real traffic. See [`LEGACY_FORMAT_VERSION_1`].
881///
882/// # No need to guard against a modified version number
883///
884/// `version` is in the AAD, so changing it to any other value makes authentication fail, and
885/// changing it to the current version changes nothing. Anything that is not the current
886/// version has already returned [`Error::SyncUnsupportedVersion`] earlier.
887fn header_aad(
888    version: u8,
889    kind: PayloadKind,
890    sync_id: &SyncId,
891    sender: &DeviceId,
892) -> [u8; AAD_LEN] {
893    debug_assert_eq!(version, FORMAT_VERSION);
894    let mut aad = [0_u8; AAD_LEN];
895    let mut offset = 0;
896    aad[..MAGIC.len()].copy_from_slice(MAGIC);
897    offset += MAGIC.len();
898    aad[offset] = version;
899    offset += 1;
900    aad[offset] = kind.as_byte();
901    offset += 1;
902    aad[offset..offset + SYNC_ID_LEN].copy_from_slice(sync_id);
903    offset += SYNC_ID_LEN;
904    aad[offset..].copy_from_slice(sender.as_bytes());
905    aad
906}
907
908/// Record kind used by inner records, shared by [`record`] and the sealing side.
909pub(crate) use crate::vault::RecordKind as InnerRecordKind;
910
911const _: () = {
912    assert!(InnerRecordKind::SyncEntry.as_byte() != InnerRecordKind::Entry.as_byte());
913    assert!(InnerRecordKind::SyncTombstone.as_byte() != InnerRecordKind::Tombstone.as_byte());
914    assert!(InnerRecordKind::SyncGroup.as_byte() != InnerRecordKind::Group.as_byte());
915};
916
917#[cfg(test)]
918mod tests {
919    use super::*;
920    use crate::otp::{Algorithm, Digits, OtpSecret};
921    use crate::vault::{
922        Entry as VaultEntry, EntryKind, RecordKind, Timestamp, Vault, master_key_to_bytes,
923    };
924
925    const _: () = assert!(LEGACY_FORMAT_VERSION_1 != FORMAT_VERSION);
926
927    /// Build one valid entry.
928    fn sample_entry(seed: u8) -> VaultEntry {
929        VaultEntry::new(
930            format!("entry {seed}"),
931            Some("组织".into()),
932            format!("user{seed}@example.com"),
933            OtpSecret::from_base32("JBSWY3DPEHPK3PXP").expect("valid Base32"),
934            Algorithm::Sha1,
935            Digits::SIX,
936            EntryKind::Totp { period: 30 },
937            String::new(),
938            Timestamp::from(1_700_000_000_u64),
939            Timestamp::from(1_700_000_000_u64),
940        )
941        .expect("valid fields")
942    }
943
944    /// Build one device identifier. The sync "sender" is a [`DeviceId`], not a 16-byte
945    /// `EntryId`.
946    fn device(seed: u8) -> DeviceId {
947        DeviceId::from_bytes([seed; DEVICE_ID_LEN])
948    }
949
950    fn a_master_key() -> MasterKey {
951        MasterKey::generate().expect("OS randomness available")
952    }
953
954    /// Open a payload record by record, yielding `(identifier, is_live_entry)` per record.
955    ///
956    /// [`Payload::records`] is indexed by identifier and **does not distinguish kinds**: a
957    /// record may be a live entry or a tombstone, and `RecordSet::get` returns `Some` for
958    /// both. Whether a given live entry was sent is answered only by decrypting and looking at
959    /// the kind, and the returned identifiers let a test assert which record was sent rather
960    /// than merely how many were.
961    /// **Group records are skipped**: they are neither entries nor tombstones, and folding them
962    /// into either list would make every assertion about that list depend on how many groups the
963    /// vault happens to have.
964    fn opened_kinds(payload: &Payload, master: &MasterKey) -> Vec<(EntryId, bool)> {
965        payload
966            .open_records(master)
967            .expect("opening record by record")
968            .into_iter()
969            .filter_map(|record| match record {
970                OpenedRecord::Entry { id, .. } => Some((id, true)),
971                OpenedRecord::Tombstone(tombstone) => Some((tombstone.entry_id, false)),
972                OpenedRecord::Group { .. } => None,
973            })
974            .collect()
975    }
976
977    /// Take the output of `seal` apart and yield the **actual plaintext segment**.
978    ///
979    /// [`Payload::open`] hands back a `Payload` with the records still **encrypted**, so the
980    /// byte layout of the wire format is invisible through the public API; faults such as "the
981    /// record length constant is miscounted" show up only at the byte level. This redoes the
982    /// parse following the **format documentation**, a second independent implementation
983    /// alongside `Payload::open`: what it checks is the layout, not internal consistency.
984    fn plaintext_of(sealed: &[u8], master: &MasterKey) -> Vec<u8> {
985        let mut reader = Reader::new(sealed);
986        assert_eq!(reader.take(MAGIC.len()), Some(&MAGIC[..]), "magic");
987        let version = reader.u8_bare().expect("version");
988        assert_eq!(version, FORMAT_VERSION, "version");
989        let kind = PayloadKind::from_byte(reader.u8_bare().expect("kind")).expect("valid kind");
990        let sync_id = reader.take(SYNC_ID_LEN).expect("sync_id");
991        let sender = reader.take(DEVICE_ID_LEN).expect("sender");
992        let nonce = reader.take(NONCE_LEN).expect("nonce");
993        let ciphertext = reader.rest();
994        assert!(reader.is_empty(), "extra bytes follow the ciphertext");
995
996        let sync_id = <[u8; SYNC_ID_LEN]>::try_from(sync_id).expect("correct length");
997        let sender = DeviceId::from_bytes(<[u8; DEVICE_ID_LEN]>::try_from(sender).expect("length"));
998        let nonce = <[u8; NONCE_LEN]>::try_from(nonce).expect("correct length");
999
1000        let aad = header_aad(version, kind, &sync_id, &sender);
1001        let envelope_key = derive_envelope_key(master).expect("derivation succeeds");
1002        let compressed = crate::vault::crypto::open_bytes(&envelope_key, &aad, &nonce, ciphertext)
1003            .expect("the outer layer must open");
1004        compress::decompress(&compressed).expect("must decompress")
1005    }
1006
1007    /// Read the record segment of the plaintext as bytes, yielding
1008    /// `(entry_id, nonce, ciphertext length)` per record.
1009    fn parse_record_frames(plaintext: &[u8]) -> Vec<([u8; EntryId::LEN], [u8; NONCE_LEN], usize)> {
1010        let mut reader = Reader::new(plaintext);
1011        let count = reader.u32_bare().expect("record count") as usize;
1012        let mut frames = Vec::with_capacity(count);
1013        for _ in 0..count {
1014            let id = reader.take(EntryId::LEN).expect("identifier");
1015            let nonce = reader.take(NONCE_LEN).expect("nonce");
1016            let len = reader.u32_bare().expect("length") as usize;
1017            reader.take(len).expect("ciphertext");
1018            frames.push((
1019                <[u8; EntryId::LEN]>::try_from(id).expect("length"),
1020                <[u8; NONCE_LEN]>::try_from(nonce).expect("length"),
1021                len,
1022            ));
1023        }
1024        assert!(reader.is_empty(), "extra bytes follow the record section");
1025        frames
1026    }
1027
1028    /// **The byte layout of the wire format must agree with `encoded_len`.**
1029    ///
1030    /// This is the only test that actually reads bytes. It re-parses the sealed payload
1031    /// following the format documentation and reconciles the result with `encoded_len`, so
1032    /// the assertion rests on a second, independent implementation.
1033    #[test]
1034    fn the_plaintext_record_framing_matches_encoded_len() {
1035        let master = a_master_key();
1036        let sender = device(7);
1037
1038        let mut vault = Vault::new();
1039        for seed in 0..3_u8 {
1040            vault
1041                .insert(sample_entry(seed))
1042                .expect("randomness available");
1043        }
1044        let doomed = vault.insert(sample_entry(9)).expect("randomness available");
1045        vault.remove(&doomed, Timestamp::from(1_700_000_100_u64));
1046
1047        let payload = Payload::from_vault(&master, sender, &vault).expect("packing succeeds");
1048        let sealed = payload.seal(&master).expect("sealing succeeds");
1049
1050        let plaintext = plaintext_of(&sealed, &master);
1051        let frames = parse_record_frames(&plaintext);
1052
1053        assert_eq!(frames.len(), payload.len(), "record count mismatch");
1054        assert_eq!(
1055            frames.len(),
1056            vault.len() + vault.tombstone_count() + vault.group_count(),
1057            "the record count in the payload must equal entries plus tombstones plus groups"
1058        );
1059
1060        for (id_bytes, nonce, len) in &frames {
1061            let id = EntryId::from_bytes(*id_bytes);
1062            let record = payload
1063                .records
1064                .get(&id)
1065                .expect("the payload must hold this record");
1066            assert_eq!(record.nonce, *nonce, "nonce mismatch");
1067            assert_eq!(record.ciphertext.len(), *len, "ciphertext length mismatch");
1068            assert_eq!(
1069                record.encoded_len(),
1070                EntryId::LEN + NONCE_LEN + 4 + len,
1071                "encoded_len does not match the actual byte count of the wire format"
1072            );
1073        }
1074
1075        let expected: usize = 4
1076            + frames.len() * (EntryId::LEN + NONCE_LEN + 4)
1077            + frames.iter().map(|f| f.2).sum::<usize>();
1078        assert_eq!(
1079            plaintext.len(),
1080            expected,
1081            "the plaintext total length does not match the wire format"
1082        );
1083    }
1084
1085    /// The plaintext contains **no** kind byte.
1086    ///
1087    /// The kind is written into the inner AAD only, so the number of deletions in a sync is
1088    /// not visible; this test pins that down so the kind is not written back into the
1089    /// plaintext later on.
1090    #[test]
1091    fn the_record_kind_is_not_in_the_plaintext() {
1092        let master = a_master_key();
1093        let sender = device(7);
1094
1095        let mut vault = Vault::new();
1096        let id = vault.insert(sample_entry(1)).expect("randomness available");
1097        vault.remove(&id, Timestamp::from(1_700_000_100_u64));
1098
1099        let payload = Payload::from_vault(&master, sender, &vault).expect("packing succeeds");
1100        let sealed = payload.seal(&master).expect("sealing succeeds");
1101        let plaintext = plaintext_of(&sealed, &master);
1102
1103        // The count, then the frames back to back: a kind byte anywhere would show up as a gap
1104        // between the two totals.
1105        let frames = parse_record_frames(&plaintext);
1106        let expected = 4 + frames
1107            .iter()
1108            .map(|(_, _, len)| EntryId::LEN + NONCE_LEN + 4 + len)
1109            .sum::<usize>();
1110        assert_eq!(
1111            plaintext.len(),
1112            expected,
1113            "the plaintext must be the header plus the frames, with no kind byte anywhere"
1114        );
1115        assert_eq!(
1116            &plaintext[4..4 + EntryId::LEN],
1117            frames[0].0.as_slice(),
1118            "the first identifier must follow the record count immediately, with no kind byte"
1119        );
1120
1121        assert_ne!(
1122            RecordKind::SyncTombstone.as_byte(),
1123            plaintext[3],
1124            "byte 4 is part of the identifier"
1125        );
1126    }
1127
1128    /// The domain separation constants must stay stable: changing them makes every existing
1129    /// payload unopenable.
1130    #[test]
1131    fn the_domain_separation_constants_are_stable() {
1132        assert_eq!(&HKDF_SALT_SYNC, b"Qualia2FA sync salt v1 (records)");
1133        assert_eq!(HKDF_SALT_SYNC.len(), 32);
1134        assert_eq!(MAGIC, b"Q2FASYNC");
1135        assert_eq!(FORMAT_VERSION, 4);
1136        assert!(
1137            crate::vault::READABLE_VERSIONS.contains(&crate::vault::FORMAT_VERSION),
1138            "the version a file format declares must be in its own readable version list"
1139        );
1140    }
1141
1142    /// **A version 1 payload must be rejected explicitly, not force-decoded as version 2.**
1143    ///
1144    /// Version 1 and version 2 differ by one `PayloadKind` byte in the outer layer, so a
1145    /// lenient parser (say, "versions up to 2 are read as the new format") would take byte 9 of
1146    /// a version 1 payload as the kind, shift the whole `sync_id` by one, and report an
1147    /// **authentication failure** — a signal pointing at a wrong key rather than at the
1148    /// version.
1149    ///
1150    /// Version 1 was never released, so there is no compatibility burden: rejection is the
1151    /// only correct behaviour.
1152    #[test]
1153    fn a_version_1_payload_is_rejected() {
1154        let master = a_master_key();
1155        let mut vault = Vault::new();
1156        vault.insert(sample_entry(1)).expect("randomness available");
1157        let payload = Payload::from_vault(&master, device(1), &vault).expect("packing succeeds");
1158        let sealed = payload.seal(&master).expect("sealing succeeds");
1159
1160        let mut legacy = sealed.clone();
1161        legacy[MAGIC.len()] = LEGACY_FORMAT_VERSION_1;
1162
1163        for result in [
1164            Payload::peek(&legacy).map(|_| ()),
1165            Payload::open(&master, &legacy).map(|_| ()),
1166        ] {
1167            match result {
1168                Err(Error::SyncUnsupportedVersion { version }) => {
1169                    assert_eq!(version, LEGACY_FORMAT_VERSION_1);
1170                }
1171                other => panic!("version 1 was not rejected as a version, actual {other:?}"),
1172            }
1173        }
1174    }
1175
1176    /// **A payload written by the previous release (version 3) is refused.**
1177    ///
1178    /// Version 4 changed three shapes at once - the group plaintext, the icon node inside an
1179    /// entry, and the width of both plaintext length prefixes - so a version 3 peer that read
1180    /// one would either fail with a misleading "malformed" diagnosis or, worse, skip the icon
1181    /// and write that loss back into its vault. The version number is what makes the refusal
1182    /// clean, and it is checked before anything is decrypted.
1183    #[test]
1184    fn a_version_3_payload_is_rejected() {
1185        let previous_release = 3_u8;
1186        let master = a_master_key();
1187        let mut vault = Vault::new();
1188        vault.insert(sample_entry(1)).expect("randomness available");
1189        let payload = Payload::from_vault(&master, device(1), &vault).expect("packing succeeds");
1190        let sealed = payload.seal(&master).expect("sealing succeeds");
1191
1192        let mut previous = sealed.clone();
1193        previous[MAGIC.len()] = previous_release;
1194
1195        for result in [
1196            Payload::peek(&previous).map(|_| ()),
1197            Payload::open(&master, &previous).map(|_| ()),
1198        ] {
1199            match result {
1200                Err(Error::SyncUnsupportedVersion { version }) => {
1201                    assert_eq!(version, previous_release);
1202                }
1203                other => {
1204                    panic!("a version 3 payload was not rejected as a version, actual {other:?}")
1205                }
1206            }
1207        }
1208    }
1209
1210    /// A **request** payload: the kind is authenticated and no records are carried.
1211    #[test]
1212    fn a_request_payload_carries_no_records_and_keeps_its_kind() {
1213        let master = a_master_key();
1214        let sender = device(3);
1215        let payload = Payload::request(sender).expect("building a request");
1216
1217        assert_eq!(payload.kind(), PayloadKind::Request);
1218        assert!(payload.is_empty(), "a request must not carry records");
1219        assert_eq!(payload.sender, sender);
1220
1221        let sealed = payload.seal(&master).expect("sealing succeeds");
1222
1223        let header = Payload::peek(&sealed).expect("peek");
1224        assert_eq!(header.kind, PayloadKind::Request);
1225        assert_eq!(header.sender, sender);
1226        assert_eq!(header.sync_id, payload.sync_id);
1227        assert_eq!(header.version, FORMAT_VERSION);
1228
1229        let opened = Payload::open(&master, &sealed).expect("opening the payload");
1230        assert_eq!(opened.kind(), PayloadKind::Request);
1231        assert!(opened.is_empty());
1232    }
1233
1234    /// **The kind is authenticated by the AAD**: flipping it from "state" to "request" must
1235    /// break authentication.
1236    ///
1237    /// If the kind were not authenticated, a third party changing a full state into a request
1238    /// would make the receiver send its entire vault back — a transfer triggered by an
1239    /// attacker that should never have happened.
1240    #[test]
1241    fn flipping_the_payload_kind_breaks_authentication() {
1242        let master = a_master_key();
1243        let mut vault = Vault::new();
1244        vault.insert(sample_entry(1)).expect("randomness available");
1245        let payload = Payload::from_vault(&master, device(1), &vault).expect("packing succeeds");
1246        let sealed = payload.seal(&master).expect("sealing succeeds");
1247
1248        let kind_offset = MAGIC.len() + 1;
1249        assert_eq!(sealed[kind_offset], PayloadKind::Vault.as_byte());
1250
1251        let mut flipped = sealed.clone();
1252        flipped[kind_offset] = PayloadKind::Request.as_byte();
1253
1254        let peeked = Payload::peek(&flipped).expect("peek only reads plaintext and must succeed");
1255        assert_eq!(peeked.kind, PayloadKind::Request);
1256
1257        let result = Payload::open(&master, &flipped);
1258        assert!(
1259            matches!(result, Err(Error::SyncAuthenticationFailed)),
1260            "the payload still opened after the kind was tampered with, actual {result:?}"
1261        );
1262
1263        assert!(Payload::open(&master, &sealed).is_ok());
1264    }
1265
1266    /// An unknown kind byte is rejected and **not** treated as a default.
1267    #[test]
1268    fn an_unknown_payload_kind_is_rejected() {
1269        let master = a_master_key();
1270        let mut vault = Vault::new();
1271        vault.insert(sample_entry(1)).expect("randomness available");
1272        let payload = Payload::from_vault(&master, device(1), &vault).expect("packing succeeds");
1273        let sealed = payload.seal(&master).expect("sealing succeeds");
1274
1275        for bogus in [0x00_u8, 0x03, 0x80, 0xFF] {
1276            let mut bad = sealed.clone();
1277            bad[MAGIC.len() + 1] = bogus;
1278            let result = Payload::peek(&bad);
1279            match result {
1280                Err(Error::SyncUnknownPayloadKind { value }) => assert_eq!(value, bogus),
1281                other => panic!("unknown kind {bogus:#04x} was not rejected, actual {other:?}"),
1282            }
1283        }
1284    }
1285
1286    /// `peek` never panics on **arbitrary** bytes and does not allocate in proportion to the
1287    /// input.
1288    #[test]
1289    fn peek_never_panics_on_arbitrary_bytes() {
1290        let cases: [&[u8]; 6] = [
1291            b"",
1292            b"Q",
1293            b"Q2FASYNC",
1294            b"Q2FASYNC\x02",
1295            b"Q2FASYNC\x02\x01",
1296            b"Q2FASYNC\x09\xff",
1297        ];
1298        for case in cases {
1299            let _ = Payload::peek(case);
1300        }
1301        let master = a_master_key();
1302        let payload = Payload::request(device(1)).expect("request");
1303        let sealed = payload.seal(&master).expect("sealing succeeds");
1304        assert!(Payload::peek(&sealed).is_ok());
1305    }
1306
1307    /// The payload header layout matches the documentation **byte for byte**.
1308    ///
1309    /// This test builds the expected prefix by hand from the documentation instead of calling
1310    /// any `seal` logic, so it is a second independent implementation.
1311    #[test]
1312    fn the_payload_header_is_laid_out_byte_for_byte() {
1313        let master = a_master_key();
1314        let sender = device(0xAB);
1315        let payload = Payload::request(sender).expect("request");
1316        let sealed = payload.seal(&master).expect("sealing succeeds");
1317
1318        assert_eq!(&sealed[..8], b"Q2FASYNC", "magic");
1319        assert_eq!(sealed[8], FORMAT_VERSION, "version");
1320        assert_eq!(sealed[9], PayloadKind::Request.as_byte(), "kind");
1321        assert_eq!(&sealed[10..42], &payload.sync_id, "sync_id");
1322        assert_eq!(&sealed[42..74], sender.as_bytes(), "sender");
1323
1324        let plaintext = plaintext_of(&sealed, &master);
1325        assert_eq!(
1326            plaintext,
1327            0_u32.to_be_bytes(),
1328            "the plaintext of an empty request must be just a zero record count"
1329        );
1330        assert!(
1331            sealed.len() >= HEADER_LEN + NONCE_LEN + TAG_LEN,
1332            "not even long enough for header + nonce + authentication tag: {} < {}",
1333            sealed.len(),
1334            HEADER_LEN + NONCE_LEN + TAG_LEN
1335        );
1336        assert_eq!(
1337            HEADER_LEN, 74,
1338            "header byte count: 8 magic + 1 version + 1 kind + 32 sync_id + 32 sender"
1339        );
1340    }
1341
1342    /// The payload and vault keys must really differ.
1343    #[test]
1344    fn the_sync_and_vault_keys_are_different() {
1345        let master = a_master_key();
1346        let record = derive_record_key(&master).expect("derivation succeeds");
1347        let envelope = derive_envelope_key(&master).expect("derivation succeeds");
1348        let vault_key = crate::vault::crypto::entry_key(&master).expect("derivation succeeds");
1349
1350        assert_ne!(
1351            record, envelope,
1352            "the record key collided with the envelope key"
1353        );
1354        assert_ne!(
1355            record, vault_key,
1356            "the sync record key collided with the vault entry key"
1357        );
1358        assert_ne!(
1359            envelope, vault_key,
1360            "the envelope key collided with the vault entry key"
1361        );
1362    }
1363
1364    /// An empty vault can be sealed and opened, and stays empty across the round trip.
1365    #[test]
1366    fn an_empty_vault_roundtrips() {
1367        let master = a_master_key();
1368        let sender = device(1);
1369        let payload =
1370            Payload::from_vault(&master, sender, &Vault::new()).expect("packing succeeds");
1371
1372        assert!(payload.is_empty(), "an empty vault must produce no records");
1373        assert_eq!(payload.len(), 0);
1374
1375        let sealed = payload.seal(&master).expect("sealing succeeds");
1376        let opened = Payload::open(&master, &sealed).expect("opening the payload");
1377        assert!(opened.is_empty());
1378        assert_eq!(opened.sender, sender);
1379        assert_eq!(opened.sync_id, payload.sync_id);
1380        assert!(
1381            opened
1382                .open_records(&master)
1383                .expect("opening record by record")
1384                .is_empty(),
1385            "an empty payload must not open any record"
1386        );
1387    }
1388
1389    /// Every payload must get a **fresh** identifier, otherwise replay protection has
1390    /// nothing to stand on.
1391    #[test]
1392    fn every_payload_gets_a_fresh_sync_id() {
1393        let master = a_master_key();
1394        let sender = device(1);
1395        let mut vault = Vault::new();
1396        vault.insert(sample_entry(1)).expect("randomness available");
1397
1398        let first = Payload::from_vault(&master, sender, &vault).expect("packing succeeds");
1399        let second = Payload::from_vault(&master, sender, &vault).expect("packing succeeds");
1400        assert_ne!(
1401            first.sync_id, second.sync_id,
1402            "two packings produced the same payload identifier"
1403        );
1404        assert_eq!(
1405            first.len(),
1406            second.len(),
1407            "two packings must yield the same record count"
1408        );
1409    }
1410
1411    /// Sealing the same vault twice must produce different ciphertext (the nonce is random
1412    /// every time).
1413    ///
1414    /// Identical output would mean the nonce is reused — the most severe misuse of
1415    /// XChaCha20-Poly1305, and one that functional tests cannot see at all (everything keeps
1416    /// working).
1417    #[test]
1418    fn sealing_the_same_vault_twice_produces_different_bytes() {
1419        let master = a_master_key();
1420        let sender = device(1);
1421        let mut vault = Vault::new();
1422        vault.insert(sample_entry(1)).expect("randomness available");
1423
1424        let first = Payload::from_vault(&master, sender, &vault).expect("packing succeeds");
1425        let second = Payload::from_vault(&master, sender, &vault).expect("packing succeeds");
1426
1427        let a = first.seal(&master).expect("sealing succeeds");
1428        let b = second.seal(&master).expect("sealing succeeds");
1429        assert_ne!(
1430            a, b,
1431            "two sealings produced identical bytes, the nonce was most likely reused"
1432        );
1433
1434        let ra = plaintext_of(&a, &master);
1435        let rb = plaintext_of(&b, &master);
1436        let fa = parse_record_frames(&ra);
1437        let fb = parse_record_frames(&rb);
1438        assert_eq!(fa.len(), fb.len());
1439        for (x, y) in fa.iter().zip(fb.iter()) {
1440            assert_eq!(x.0, y.0, "same entry");
1441            assert_ne!(x.1, y.1, "the same record reused its nonce");
1442        }
1443    }
1444
1445    /// When a live entry and its tombstone coexist in the vault, **only** the tombstone is
1446    /// sent.
1447    ///
1448    /// # How that state arises
1449    ///
1450    /// It is **not** produced by [`Vault::remove`]: by design that removes the entry and
1451    /// leaves a tombstone behind, so it never leaves "entry and tombstone side by side". The
1452    /// state comes from sync itself: [`crate::vault::Vault::record_tombstone`] never deletes
1453    /// an existing live entry with the same ID, and the caller decides the conflict
1454    /// resolution outcome. A device that receives a tombstone from the peer and does not
1455    /// adopt it automatically stays in this state until the user decides.
1456    ///
1457    /// The state is a real and common one: two devices each hold an entry with the same name
1458    /// and ID, one of them deletes it, and the other stays like this while the adjudication
1459    /// screen is up. Broadcasting the live entry would trigger the same conflict on the peer
1460    /// at every sync, and the user would face the same adjudication screen every time.
1461    #[test]
1462    fn an_entry_with_a_tombstone_is_not_sent() {
1463        let master = a_master_key();
1464        let sender = device(1);
1465
1466        let mut vault = Vault::new();
1467        let kept = vault.insert(sample_entry(1)).expect("randomness available");
1468        let disputed = vault.insert(sample_entry(2)).expect("randomness available");
1469
1470        vault.record_tombstone(crate::vault::Tombstone {
1471            entry_id: disputed,
1472            deleted_at: Timestamp::from(1_700_000_100_u64),
1473            deleted_by: None,
1474        });
1475
1476        assert_eq!(
1477            vault.len(),
1478            2,
1479            "precondition failed: both live entries must still be there"
1480        );
1481        assert_eq!(
1482            vault.tombstone_count(),
1483            1,
1484            "precondition failed: a tombstone must exist"
1485        );
1486        assert!(
1487            vault.get(&disputed).is_some(),
1488            "precondition failed: `record_tombstone` must not delete a live entry"
1489        );
1490
1491        let payload = Payload::from_vault(&master, sender, &vault).expect("packing succeeds");
1492        assert_eq!(
1493            payload.len(),
1494            3,
1495            "there must be exactly three records: the kept entry, the tombstone, and the top group"
1496        );
1497        assert!(
1498            payload.records.get(&kept).is_some(),
1499            "the kept entry must be sent"
1500        );
1501
1502        let records = opened_kinds(&payload, &master);
1503        let mut entries = Vec::new();
1504        let mut tombstones = Vec::new();
1505        for (id, is_entry) in records {
1506            if is_entry {
1507                entries.push(id);
1508            } else {
1509                tombstones.push(id);
1510            }
1511        }
1512        assert_eq!(
1513            entries,
1514            vec![kept],
1515            "exactly one live entry must be sent, and it must be the kept one"
1516        );
1517        assert_eq!(
1518            tombstones,
1519            vec![disputed],
1520            "exactly one tombstone must be sent"
1521        );
1522        assert!(
1523            !entries.contains(&disputed),
1524            "a live entry with a tombstone was sent as an entry"
1525        );
1526    }
1527
1528    /// The record count limit is checked **outside the loop**.
1529    ///
1530    /// Otherwise a payload claiming four billion records could make us allocate a container
1531    /// of four billion items before noticing anything is wrong. Only a structural check is
1532    /// possible here: build a plaintext that claims an over-limit count and confirm it is
1533    /// rejected rather than allocated for.
1534    #[test]
1535    fn an_absurd_record_count_is_rejected_before_allocating() {
1536        let master = a_master_key();
1537        let mut vault = Vault::new();
1538        vault.insert(sample_entry(1)).expect("randomness available");
1539        let payload = Payload::from_vault(&master, device(1), &vault).expect("packing succeeds");
1540
1541        let mut plaintext = u32::MAX.to_be_bytes().to_vec();
1542        plaintext.extend_from_slice(&[0_u8; 64]);
1543        let compressed = compress::compress(&plaintext).expect("compression succeeds");
1544
1545        let envelope_key = derive_envelope_key(&master).expect("derivation succeeds");
1546        let aad = header_aad(
1547            FORMAT_VERSION,
1548            PayloadKind::Vault,
1549            &payload.sync_id,
1550            &payload.sender,
1551        );
1552        let (nonce, ciphertext) =
1553            crate::vault::crypto::seal_bytes(&envelope_key, &aad, &compressed)
1554                .expect("sealing succeeds");
1555
1556        let mut sealed = Vec::new();
1557        sealed.extend_from_slice(MAGIC);
1558        sealed.push(FORMAT_VERSION);
1559        sealed.push(PayloadKind::Vault.as_byte());
1560        sealed.extend_from_slice(&payload.sync_id);
1561        sealed.extend_from_slice(payload.sender.as_bytes());
1562        sealed.extend_from_slice(&nonce);
1563        sealed.extend_from_slice(&ciphertext);
1564
1565        let result = Payload::open(&master, &sealed);
1566        assert!(
1567            matches!(result, Err(Error::SyncTooManyRecords { .. })),
1568            "an over-limit record count was not rejected, actual {result:?}"
1569        );
1570    }
1571
1572    /// The master key bytes come from the public API instead of being assembled by the test.
1573    #[test]
1574    fn the_master_key_roundtrips_through_bytes() {
1575        let master = a_master_key();
1576        let copy = MasterKey::from_bytes(master_key_to_bytes(&master)).expect("correct length");
1577        assert_eq!(
1578            derive_record_key(&master).expect("derivation succeeds"),
1579            derive_record_key(&copy).expect("derivation succeeds"),
1580            "a copy of the same master key must derive the same record key"
1581        );
1582    }
1583}