fix(protocol,mls): report a key package refused by this device's clock - #506
Merged
Merged
Conversation
A peer's key package is valid from an hour before it was minted, judged by the receiver's clock. Between two devices whose clocks disagree by more than that, no session forms and messages and connection requests stay pending forever, and the only trace was a debug line. Two Android phones that had never been online (one set to February 2024, one to October 2025) reproduced it during the v0.28 smoke test. - MlsError::KeyPackageOutsideValidityWindow, from OpenMLS's KeyPackageVerifyError::InvalidLifetime, on every route that admits a peer's package (import, cache read, add_group_member) through one helper. - SecurityWarningCode::KeyPackageOutsideValidityWindow (KEY_PACKAGE_OUTSIDE_VALIDITY_WINDOW), raised by establish_secure_session once per peer through the control-gate throttle: the package has not proved its sender when its window is checked. - The window itself is unchanged.
The previous commit reported a key package refused by this device's clock, but only from the arrival-time route. The send path has its own near-identical copy of import-then-create-session, and that copy still did a bare `?` on the import. It is the route taken on the first send after a restart (the warning throttle lives in memory and the package comes back from storage) and whenever auto key exchange is off. There the send kick swallowed the error into a log line and queued the message "anyway". The app heard nothing. Which is exactly the failure this whole change exists to fix, just on the other half of the pair. It also treated both ends of the window as a clock fault. A window that has not started is one. A window that has closed may be one, or may just be an old package: the receiver anchors its cached expiry to when the frame arrived, using a remaining lifetime the sender computed when it built the frame. So a relay that held the package for days hands us a package whose cached expiry overshoots its real not_after. In that gap we told the user to check a clock that was fine, and then kept the package, so every attempt failed hard until the cached expiry. Let's fix both. One engine helper now admits a pending package for both routes and does the reporting, and the throttle keeps it to one event per peer whichever route fires first. The MLS error says which end refused the package. A window that has not started keeps the package, since it becomes valid once the clocks agree. A closed one is discarded the same way an expired package already is, and the reason names both causes. OpenMLS exposes no lifetime on an unvalidated KeyPackageIn outside its test-utils feature, so the direction is read back through its serde form, and only on the failure path. That is not pretty, but the alternative is walking the TLS encoding by hand, which is a second key package codec. Please don't do that. If the shape ever changes, the answer falls back to "not started", which is how the previous commit behaved, and the tests on both ends will say so.
bahdotsh
force-pushed
the
fix/key-package-clock-skew
branch
from
October 6, 2026 04:51
08805c2 to
320df7e
Compare
bahdotsh
approved these changes
Oct 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found in the v0.28 device smoke test. Two Android phones that had never been online had clocks set to February 2024 and October 2025. They discovered each other and proved their streams, but a connection request then stayed "Pending" forever on both, and no session ever formed. Nothing reached the app. The only trace was a debug line in
message_dispatch:Threat-model R7b already accepts that a device with a wrong clock refuses honest peers. Its stated mitigation is that the refusal is reported under its own code, so a local fault reads as one. That is done for control frames (
STALE_CONTROL_FRAME), but not for key packages. This PR reports key packages too. It does not relax the window.The window, measured on the phones
A package is valid from 1 h before minting to 30 days after, judged by the receiver's clock. So:
Change
MlsError::KeyPackageOutsideValidityWindow. Mapped from OpenMLS'sKeyPackageVerifyError::InvalidLifetimein one helper,validate_peer_key_package. The helper now covers all three routes that admit a peer's package: import, the cache read, andadd_group_member. Every other refusal keeps its old type and text, including the width cap.SecurityWarningCode::KeyPackageOutsideValidityWindow(KEY_PACKAGE_OUTSIDE_VALIDITY_WINDOW). Raised byestablish_secure_sessionand logged atwarn. Emitted at most once per peer through the control-gate throttle (bit 5): the package has not proved its sender when its window is checked, so the peer id is attacker-chosen.SecurityWarningCodeunion and its docs, the local-API spec's code list, threat-model R7b, and the changelog. An exhaustive TSswitchover the union needs a new arm, which the changelog notes.Validation
On the phones (Infinix NOTE 12 on Android 13, Seeker on Android 15, Wi-Fi Direct, built from this branch plus #505):
Locally:
cargo test --workspace --locked: all green, 2007 tests inoffline-protocol.a_key_package_outside_its_window_by_our_clock_is_reported_once_per_peer, with three retries giving one event.cargo clippy --workspace --locked -- -D warningsand the--no-default-featuresvariant: clean. My local clippy (1.94) also flagsdata_sync.rs:719, which is untouched main code that CI's stable accepts; I allowed that one lint to check the rest.RUSTDOCFLAGS="-D warnings" cargo docfor both crates: clean.cargo fmt --check: clean.Risk
#[non_exhaustive]enums.Test fixture:
MlsManager::key_package_with_window_for_testingbehind the existingtest-utilsfeature.