offline-protocol-sealed 0.30.0

Sealed-envelope codec, address derivation and canonical signing payloads for the Offline Protocol SDK
Documentation
//! Numbers a phone and a leaf node must configure identically.
//!
//! Each of these is a value that appears on both sides of a sealed
//! conversation. A copy that drifts does not fail a build or a test: it
//! produces a peer that is configured differently from the one it is talking
//! to, and the symptom arrives later, on a device, as a message that will not
//! decrypt.

use core::time::Duration;

/// The metadata key naming the message a delivery acknowledgement answers.
///
/// An acknowledgement is not a control frame. It carries no prefix and no
/// signature: it is an ordinary message with empty content whose whole meaning
/// is this one metadata entry, so a receiver that does not know this string
/// reads a blank message and a sender that does not write it is never
/// answered. That makes it exactly the kind of number this module holds, and
/// the reason it is not left in the engine, where a leaf node cannot reach it
/// and would have to spell it again.
///
/// It is deliberately the *only* entry a leaf writes. The other two the engine
/// puts on its own acknowledgements say which hop count and which carrier the
/// answer came back over, and both have defaults that are already right for a
/// device: no hops, because a leaf is a direct peer, and BLE, because that is
/// the radio a leaf is paired over. A device does not own its transport, so
/// naming one would be firmware's guess crossing the wire as fact.
pub const ACK_FOR_KEY: &str = "ack_for";

/// How many generations behind the latest decrypted message a sender ratchet
/// key is kept, so late/reordered messages remain decryptable.
///
/// The OpenMLS default (5) is smaller than the windowed media transfer's
/// in-flight budget (up to 8 chunks on internet transports, interleaved with
/// text on the same 1:1 session ratchet), which would make a sufficiently
/// delayed chunk *permanently* undecryptable and stall the transfer. 32 gives
/// 4x headroom over the largest window at the cost of retaining up to 32
/// unused message keys per sender ratchet.
///
/// The protocol layer keeps the combined in-flight budget within this bound
/// by capping concurrent media transfers per peer
/// (`MAX_CONCURRENT_MEDIA_TRANSFERS_PER_PEER` in `offline-protocol`); revisit
/// both together if either changes.
pub const SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE: u32 = 32;

/// How far ahead of the highest seen generation a sender ratchet may be
/// fast-forwarded when messages are lost.
///
/// A receiver more than this far behind cannot decrypt anything from that
/// sender again until the pair re-keys, so the window has to sit well above
/// the gap a congested link can open. The engine keeps up to
/// `DEFAULT_MAX_PENDING_ACKS` (1000) messages in flight and re-seals each
/// resend, which spends a fresh generation every time; a receiver that drops
/// a resend as a duplicate of a message it already has skips that generation.
/// The OpenMLS default of 1000 was therefore reachable, and the 2026-10-19
/// volume test reached it: the sender logged 12,152 retries against 2,434
/// messages the receiver decrypted. 8192 keeps more than four in-flight
/// ceilings of headroom (pinned in `offline-protocol`), and the cost of using
/// it is one fast-forward of up to 8192 key derivations when a gap opens.
///
/// It is receiver-local policy: nothing on the wire carries it, and a peer
/// configured with a different value still interoperates. It configures the
/// phone's OpenMLS groups only: a leaf's receiver window is
/// [`LEAF_SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE`], which mls-rs does not let
/// anyone set.
pub const SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE: u32 = 8192;

/// How far ahead a leaf node's receiver ratchet may be fast-forwarded: a frame
/// more than this many generations past the next one it expects is refused.
///
/// Not configuration. mls-rs fixes it (`MAX_RATCHET_BACK_HISTORY` in its
/// secret tree, 1024 in the pinned 0.56.0) and exposes no setting, so a phone
/// sealing for a leaf has this much room, not
/// [`SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE`]. Anything a phone derives from
/// "how far ahead a receiver tolerates" has to take the smaller of the two:
/// the re-seal budget in `offline-protocol` does. Stated here so that
/// derivation has one name to read, and pinned against the real mls-rs by
/// `tools/mls-interop`, which fails if an mls-rs upgrade moves it.
pub const LEAF_SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE: u32 = 1024;

/// How long a leaf node's key package claims to be valid.
///
/// mls-rs defaults to a year. 28 days is what RFC 9420 asks an application to
/// define, and it bounds how long an unused init key stays usable.
///
/// It clears [`MAX_ACCEPTED_KEY_PACKAGE_LIFETIME`] with three weeks to spare,
/// which is not a coincidence to be tidied away: a leaf that minted a window
/// wider than the phone accepts would be refused at the one moment it has no
/// way to hear why, and the pairing would fail on a device with no screen.
/// The assertion below is what stops one number moving without the other.
pub const LEAF_KEY_PACKAGE_LIFETIME: Duration = Duration::from_secs(28 * 24 * 3600);

/// The widest validity window this SDK will admit in a key package it did not
/// mint, at import and on every read of the cache.
///
/// RFC 9420 requires an application to define this and reject anything longer,
/// and until issue 396 nothing here did. OpenMLS ships both halves of the rule
/// and wires up neither: it declares `MAX_LEAF_NODE_LIFETIME_RANGE_SECONDS`
/// (an hour plus three months) and never calls `Lifetime::has_acceptable_range`,
/// so `KeyPackageIn::validate` checks only that *now* falls inside the window.
/// A package claiming a century passed.
///
/// # Why the window is what ages a key package out
///
/// A key package is admitted once and cached in the install-scoped
/// protocol-state store, where it stays usable for establishing new sessions
/// until the window closes. That window is the only thing that expires it, so
/// an unbounded one turns a single leaked init key into a permanent capability
/// to open a session as its owner. Bounding what we mint and admitting anything
/// inbound bounds nothing.
///
/// # Why 90 days and not the OpenMLS constant
///
/// The tempting cap is the one OpenMLS declares, and it is the wrong number by
/// exactly zero seconds. An `openmls` key package built without an explicit
/// lifetime carries the library's default of three months plus a one-hour skew
/// margin, which *is* `MAX_LEAF_NODE_LIFETIME_RANGE_SECONDS`. Every key package
/// this SDK minted before issue 396 is one of those, so a cap set there admits
/// them with no margin at all, and any peer whose skew margin is a second wider
/// is refused. 90 days sits above every window this SDK has ever put on the
/// wire and far below the year mls-rs hands out by default.
pub const MAX_ACCEPTED_KEY_PACKAGE_LIFETIME: Duration = Duration::from_secs(90 * 24 * 3600);

// A leaf's own package has to survive the phone's gate. Checked at compile time
// because the failure is otherwise a pairing that fails in the field, on the
// one device that cannot report why.
const _: () = assert!(
    LEAF_KEY_PACKAGE_LIFETIME.as_secs() <= MAX_ACCEPTED_KEY_PACKAGE_LIFETIME.as_secs(),
    "a leaf mints a window the phone refuses"
);

/// How far into the past a leaf's key package validity window has to start.
///
/// OpenMLS tests `not_before < now`, strictly. mls-rs's client builder writes
/// `not_before` as exactly the timestamp it is handed, without the backdating
/// its own `Lifetime::seconds` helper applies, so a package stamped with the
/// current second is refused for being not yet valid. This is also the margin
/// that absorbs clock skew between the two devices.
///
/// A leaf has no clock of its own to stamp with. ADR 0021 records the
/// consequence: it needs a time source at pairing.
pub const LEAF_KEY_PACKAGE_NOT_BEFORE_BACKDATE_SECONDS: u64 = 3600;