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(©).expect("derivation succeeds"),
1580 "a copy of the same master key must derive the same record key"
1581 );
1582 }
1583}