pub struct Payload {
pub sync_id: SyncId,
pub sender: DeviceId,
pub records: RecordSet<Record>,
/* private fields */
}Expand description
The content of a payload: every record to be sent this time.
Records are held in a RecordSet, keyed by EntryId: union merging treats the same
entry appearing twice in one payload as meaningless, so the set type carries the “at most
one record per entry” invariant and a duplicate is reported as a structural error while
parsing.
§Record order is deterministic
RecordSet is a BTreeMap internally and iterates in ascending EntryId order, so
the same content serializes to a byte-identical plaintext. That is not the same as
“byte-identical payload”: every record’s nonce is random, so the ciphertexts differ.
Determinism is useful for exact byte comparisons in tests and gives a cheap criterion for
“do two payloads carry the same data”.
Fields§
§sync_id: SyncIdIdentifier of this sync. The receiver uses it to recognize duplicate payloads.
sender: DeviceIdThe device that started this sync.
Takes part in the outer AAD, so it is authenticated: an attacker cannot change a payload’s sender to somebody else. Merging needs to know who is initiating, because conflict resolution is decided by the initiator.
§It is a device identifier, but does not take part in device authentication
The “sender” here is only an authenticated claim: it proves that the party
holding the master key claims to be this device. Actually proving the device identity
(private key in hardware) is the job of crate::auth, which is a different path.
Neither replaces the other: this field stops a payload from being attributed to
somebody else, which device authentication does not.
records: RecordSet<Record>The records in this payload, still encrypted.
Implementations§
Source§impl Payload
impl Payload
Sourcepub fn new_sync_id() -> Result<SyncId>
pub fn new_sync_id() -> Result<SyncId>
Generate a fresh payload identifier.
§Errors
Error::RandomnessUnavailable: the OS random source is unavailable. There must be
no fallback to a userspace PRNG: a predictable payload identifier defeats replay
protection, because an attacker could precompute an identifier that will be accepted
later.
Sourcepub const fn kind(&self) -> PayloadKind
pub const fn kind(&self) -> PayloadKind
What this payload asks the peer to do.
Sourcepub fn request(sender: DeviceId) -> Result<Self>
pub fn request(sender: DeviceId) -> Result<Self>
Build a request: send me your full state.
A request is a payload like any other: the same transport, the same authentication, and the same replay protection.
A request carries no records (see PayloadKind::Request), so it is the
fixed-length header plus a nonce and the authentication tag — HEADER_LEN +
NONCE_LEN + TAG_LEN = 114 bytes plus the compressed plaintext — far smaller
than any real full state.
“Small” is relative; it does not mean “always fits the smallest QR code”. The
request carries the 32-byte device identifier and the 32-byte payload identifier plus
the authentication tag, so it takes up a substantial part of the capacity of any QR
code; the smallest versions cannot hold it. Whether it fits is answered by the
channel: crate::transport::TransportCapabilities::check_fits can tell before
sending.
§Errors
Error::RandomnessUnavailable: the random source is unavailable.
Sourcepub fn from_vault(
master: &MasterKey,
sender: DeviceId,
vault: &Vault,
) -> Result<Self>
pub fn from_vault( master: &MasterKey, sender: DeviceId, vault: &Vault, ) -> Result<Self>
Build a payload from the local vault.
It seals all live entries and all tombstones. Incremental sync will be a filter parameter on this function rather than a different payload format.
§When an entry and its tombstone coexist, only the tombstone is sent
A vault can hold a live entry together with its tombstone — the state in which
adjudication has not finished (crate::vault::Vault::record_tombstone never deletes
the live entry). In that state the live entry must not be sent too: the peer would
read it as “this one is not deleted yet”, so every sync would trigger the same
conflict on the peer and the user would face the same adjudication screen again on
every sync.
A tombstone represents a deletion that has already happened, while that live entry is local state awaiting adjudication. Broadcasting state awaiting adjudication would make every device take a decision the user has not taken yet.
§The record key is derived once
A payload can hold tens of thousands of records, and deriving HKDF again per record would multiply two HMAC-SHA256 computations by the number of entries. Deriving once and passing that key to every record reduces the cost from O(n) derivations to one.
§Errors
Error::SyncTooManyRecords: the sum of entries and tombstones exceedsMAX_RECORDS.Error::RandomnessUnavailable: the random source is unavailable.- Sealing failure (see
record::seal_entry).
Sourcepub fn peek(data: &[u8]) -> Result<PayloadHeader>
pub fn peek(data: &[u8]) -> Result<PayloadHeader>
Read a payload header without decrypting anything.
Replay protection takes effect before the vault is touched, and the accounting needs
the sync_id, which lives in the plaintext header. peek answers “have I seen this
identifier” in a single call and allocates nothing large for any input, so a garbage
payload (tampered ciphertext, or plain random bytes) is rejected without a decompression
attempt and without its claimed identifier having been recorded.
§It authenticates nothing
Every returned field comes from the plaintext header, so it is only a claim. The
one legitimate use is looking the sync_id up in the replay log: a hit is rejected
outright (rejecting an unauthenticated payload needs no justification), and a miss
still has to go through the full Payload::open.
§Errors
The same header checks as Payload::open: magic, version, length.
Sourcepub fn is_empty(&self) -> bool
pub fn is_empty(&self) -> bool
Whether there is no record at all.
An empty payload is legal: that is exactly what a freshly registered device with no entries yet sends when it starts a sync. It must be accepted normally (the peer learns from it that there is nothing on this side), not treated as an error — that would add a branch needing a special case on the “new device joins” path.
Sourcepub fn seal(&self, master: &MasterKey) -> Result<Vec<u8>>
pub fn seal(&self, master: &MasterKey) -> Result<Vec<u8>>
Pack the payload into bytes that can be carried around.
The order is serialize → compress → whole-payload AEAD.
§Errors
Error::SyncTooManyRecords: the number of records exceedsMAX_RECORDS.Error::SyncMalformed: the packed plaintext exceedsMAX_PLAINTEXT_LEN.Error::RandomnessUnavailable: the random source is unavailable.
Sourcepub fn open(master: &MasterKey, data: &[u8]) -> Result<Self>
pub fn open(master: &MasterKey, data: &[u8]) -> Result<Self>
Take apart bytes carried in from outside, yielding records that are still encrypted.
§It only handles the outer layer
The returned Record values are not decrypted yet; that takes a call to
record::open per record. Splitting the two steps is deliberate: an outer
authentication failure and one unopenable record are different faults (the whole
payload is untrustworthy versus the payload is trustworthy but its content is broken),
and callers handle them differently — the former can only be retried, the latter
deserves a specific diagnosis.
§Errors
Error::SyncBadMagic: not a payload of this application.Error::SyncUnsupportedVersion: unsupported version.Error::SyncTruncated: not enough bytes.Error::SyncAuthenticationFailed: outer authentication failed, with no distinction of cause.Error::SyncEmptyPayload/Error::SyncDecompressionFailed: compression layer.- Other structural errors.
§This function must never panic
The input comes entirely from a channel an attacker controls (any device on the LAN,
or any byte string). All out-of-range access is stopped by the read cursor: the end
offset is computed with checked_add and then read through get(range), so both
overflow and out-of-range access return None instead of panicking.
Sourcepub fn open_records(&self, master: &MasterKey) -> Result<Vec<OpenedRecord>>
pub fn open_records(&self, master: &MasterKey) -> Result<Vec<OpenedRecord>>
Open every record in the payload.
§One failure fails the whole payload
It does not “skip what cannot be opened and take the rest”: the outer layer is authenticated, so a record that does not open can only mean the producer used the wrong key (for instance two devices whose vaults come from different master keys), and partial success would make such a fault look like “the sync succeeded but a few entries are missing” — the hardest kind to diagnose.
§Errors
Error::SyncAuthenticationFailed: one record does not open.- Other structural errors (see
record::open).