Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,25 @@ archived by series under [docs/changelog/](docs/changelog/); see the
Found while running it: a message that a direct stream took and then lost
(a device that went away without closing the stream) is retried over
direct carriers only and never handed to the mesh (#541).
- **`message_decryption_failed` says how old the frame is.** Two new fields,
`sealed_at_ms` (when the sender sealed the frame, sender clock, from the
cleartext envelope) and `session_established_at_ms` (when this device's
current 1:1 session with the sender was created or adopted, local clock),
both `null` when unknown. A frame sealed before the session existed cannot
have been sealed under it, so its failure is not evidence that the two sides
hold different sessions; an app's split-brain breaker can now leave it out.
The session time is recorded from this version on; a session established
earlier reports `null` until it is rebuilt.
- **Key packages advertise `ack_versions`.** A new capability list on the
key package payload, persisted per peer with the others. Entry 1
(`ACK_UNDECRYPTABLE_V1`) says the sender reads `ack_status:
"undecryptable"` as "not delivered"; this version advertises it
unconditionally, and answers an undecryptable frame only to a peer that
advertised it, because an older SDK reads any ACK as a delivery. A peer
learns it from our next key package, which this SDK pushes on the first
discovery of each peer per launch (any inbound frame from them, a BLE
sighting, or a presence edge). Leaf devices do not advertise it. See
[Capability negotiation](docs/spec/capability-negotiation.md).

### Changed

Expand Down Expand Up @@ -232,6 +251,40 @@ archived by series under [docs/changelog/](docs/changelog/); see the

### Fixed

- **A frame that can never decrypt gets an answer, and is not redelivered
forever.** A frame that failed with a spent ratchet generation or a dead
epoch, or that the pending-decryption queue gave up on, was left
unacknowledged so the sender would resend it re-sealed. A relay holding the
frame for an offline recipient read the silence as "not delivered yet" and
redelivered the same dead ciphertext on every connect, where it failed
again. The recipient now answers it with a delivery ACK carrying
`metadata["ack_status"] = "undecryptable"`, which a relay settles like any
ACK, but only when the frame arrived over the internet relay (these
failures are unauthenticated, so an answer on a mesh link would confirm
liveness to an injector in range) and the sender advertised `ack_versions: [1]` in its key
package (see Added). A sender that has not, or whose key package this
device has not seen since it upgraded, is answered with nothing, exactly as
before. The sender never reports such a message delivered: it re-seals the DM
under its current session and resends it once with the same id (verbatim
when the plaintext is gone, as for an outbox entry restored after a restart,
since a frame the recipient's queue dropped unattempted still decrypts); a
second answer for the same id resends nothing and leaves the entry to its
retry ladder, which ends in `message_failed` at max retries. Media chunks and frames whose envelope does not
parse keep their old handling. See
[Reserved metadata keys](docs/spec/wire-format.md#reserved-metadata-keys).
- **Frames the SDK sends on its own no longer push "new message" to an
offline peer.** A data-sync offer, a key package or a typing frame is the
same MLS ciphertext to the relay as a user's text, so a device that
reconnected or heard from a peer sent pushes with nothing behind them.
Every engine-originated frame now carries `metadata["no_alert"] = "1"` in
the cleartext envelope for the relay to honour. User messages (plain, rich,
forwarded, media), connection requests and accepts, group chat messages and
group invites do not. A document carried over the media path is the SDK's
own, so every one of its chunks carries it. A 1:1 Welcome carries it only
when no user message is waiting on the session, decided again on every
resend, and never for a session a document transfer opens. The key is
unsigned and receivers ignore it. See
[Reserved metadata keys](docs/spec/wire-format.md#reserved-metadata-keys).
- **A message the mesh carried settles when its recipient answers.** A direct
message to a peer no carrier could reach was handed to neighbours to carry,
but registered no pending acknowledgement, and an acknowledgement with none
Expand Down
13 changes: 13 additions & 0 deletions bindings/react-native/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,19 @@ export interface MessageDecryptionFailedEvent extends BaseEvent {
sender: string;
code: DecryptionFailureCode;
reason: string;
/**
* When the sender sealed the frame (sender clock, ms), from the cleartext
* envelope. Unauthenticated: a hint, never a security input. `null` unless
* this was a 1:1 decrypt attempt.
*/
sealed_at_ms: number | null;
/**
* When this device's current session with `sender` was created or adopted
* (local clock, ms); `null` when unknown. A frame sealed before it cannot
* have been sealed under this session, so its failure says nothing about
* whether the two sides hold the same session now.
*/
session_established_at_ms: number | null;
}

/**
Expand Down
1 change: 1 addition & 0 deletions crates/offline-protocol-leaf/src/keypkg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ pub(crate) fn payload(
// the one that introduced the device at all.
ctrl_versions: alloc::vec![CTRL_SIGN_V2],
svc_versions: Vec::new(),
ack_versions: Vec::new(),
nostr_pubkey: None,
}
}
9 changes: 9 additions & 0 deletions crates/offline-protocol-leaf/tests/phone_interop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ fn a_key_package_that_claims_another_owner_is_refused() {
data_versions: vec![],
ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2],
svc_versions: Vec::new(),
ack_versions: Vec::new(),
nostr_pubkey: None,
};

Expand Down Expand Up @@ -1139,6 +1140,7 @@ fn a_peer_that_already_has_a_key_package_is_not_sent_another() {
data_versions: vec![],
ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2],
svc_versions: Vec::new(),
ack_versions: Vec::new(),
nostr_pubkey: None,
};
let frame = phone_control_frame(
Expand Down Expand Up @@ -1723,6 +1725,7 @@ fn a_replayed_session_reset_does_not_tear_down_the_new_session() {
data_versions: vec![],
ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2],
svc_versions: Vec::new(),
ack_versions: Vec::new(),
nostr_pubkey: None,
};
let reset_frame = phone_control_frame(
Expand Down Expand Up @@ -1822,6 +1825,7 @@ fn a_flood_of_strangers_cannot_displace_an_established_peer() {
data_versions: vec![],
ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2],
svc_versions: Vec::new(),
ack_versions: Vec::new(),
nostr_pubkey: None,
};
let frame = phone_control_frame(
Expand Down Expand Up @@ -1854,6 +1858,7 @@ fn a_flood_of_strangers_cannot_displace_an_established_peer() {
data_versions: vec![],
ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2],
svc_versions: Vec::new(),
ack_versions: Vec::new(),
nostr_pubkey: None,
};
let frame = phone_control_frame(
Expand Down Expand Up @@ -2280,6 +2285,7 @@ fn advertisement_body(user_id: &str) -> String {
data_versions: vec![],
ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2],
svc_versions: Vec::new(),
ack_versions: Vec::new(),
nostr_pubkey: None,
};
format!(
Expand Down Expand Up @@ -3209,6 +3215,7 @@ fn a_reset_older_than_the_last_one_acted_on_is_refused() {
data_versions: vec![],
ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2],
svc_versions: Vec::new(),
ack_versions: Vec::new(),
nostr_pubkey: None,
};
phone_control_frame_at(
Expand Down Expand Up @@ -3270,6 +3277,7 @@ fn a_spent_reset_stays_spent_across_a_power_cycle() {
data_versions: vec![],
ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2],
svc_versions: Vec::new(),
ack_versions: Vec::new(),
nostr_pubkey: None,
};
let reset_frame = phone_control_frame_at(
Expand Down Expand Up @@ -3421,6 +3429,7 @@ fn a_session_reset_that_states_no_time_is_refused_its_teardown() {
data_versions: vec![],
ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2],
svc_versions: Vec::new(),
ack_versions: Vec::new(),
nostr_pubkey: None,
};
let content = format!(
Expand Down
86 changes: 84 additions & 2 deletions crates/offline-protocol-mls/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -992,13 +992,40 @@ impl MlsManager {

let key_type = StorageKeyType::ContactKeyPackage.as_str();
self.storage.delete(key_type, other_user_id)?;
self.stamp_session_established(&welcome.group_id);

Ok(welcome)
}

/// Joins a session using a Welcome message.
pub fn join_session(&self, welcome: &WelcomeMessage) -> Result<GroupInfo> {
self.session_manager.join_session(welcome)
let info = self.session_manager.join_session(welcome)?;
self.stamp_session_established(&welcome.group_id);
Ok(info)
}

/// When the current 1:1 session with `other_user_id` was created or
/// adopted on this device (local clock, ms), or `None` when there is no
/// session or it predates the stamp.
///
/// A frame sealed before this moment cannot have been sealed under this
/// session, so its failure to decrypt says nothing about whether the two
/// sides hold the same session now.
pub fn session_established_at_ms(&self, other_user_id: &str) -> Result<Option<u64>> {
let session_id = self.session_manager.get_session_id(other_user_id)?;
Ok(self
.load_group_metadata(&session_id)?
.map(|metadata| metadata.created_at_ms))
}

/// Records the moment a session's group state was written, in the
/// group-metadata slot a 1:1 session otherwise leaves empty. Best effort:
/// the session exists either way, and a missing stamp only means
/// [`Self::session_established_at_ms`] answers `None`.
fn stamp_session_established(&self, session_id: &GroupId) {
if let Err(e) = self.save_group_metadata(session_id, &GroupMetadata::new(None)) {
warn!(session_id = %session_id, error = %e, "Failed to stamp session establishment time");
}
}

/// Replaces an existing session with an incoming Welcome message.
Expand Down Expand Up @@ -1032,7 +1059,7 @@ impl MlsManager {
// Join using their Welcome. `join_session` adopts non-destructively
// (stage-then-swap), so a retransmitted Welcome that re-stages is a safe
// no-op rather than deleting and re-creating our existing session.
self.session_manager.join_session(welcome)
self.join_session(welcome)
}

/// Encrypts a message for a 1:1 session.
Expand Down Expand Up @@ -1115,7 +1142,15 @@ impl MlsManager {
}

/// Deletes a 1:1 session.
///
/// The establishment stamp goes first: an `Err` must mean the session is
/// still there, because callers clean up their own state for the peer
/// only once this succeeds. Deleting the session first and then failing
/// on the stamp left a peer recorded as confirmed with no session.
pub fn delete_session(&self, other_user_id: &str) -> Result<()> {
let session_id = self.session_manager.get_session_id(other_user_id)?;
self.storage
.delete(StorageKeyType::GroupMetadata.as_str(), session_id.as_str())?;
self.session_manager.delete_session(other_user_id)
}

Expand Down Expand Up @@ -3426,6 +3461,53 @@ mod tests {
(alice, bob)
}

use crate::storage::{StorageError, StorageResult};

/// Storage that refuses to delete a session's establishment stamp.
struct StampDeleteFails(InMemoryStorage);

impl MlsStorage for StampDeleteFails {
fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> StorageResult<()> {
self.0.store(key_type, key_id, data)
}
fn load(&self, key_type: &str, key_id: &str) -> StorageResult<Option<Vec<u8>>> {
self.0.load(key_type, key_id)
}
fn delete(&self, key_type: &str, key_id: &str) -> StorageResult<()> {
if key_type == StorageKeyType::GroupMetadata.as_str() {
return Err(StorageError::DeleteFailed("refused".to_string()));
}
self.0.delete(key_type, key_id)
}
fn list_keys(&self, key_type: &str) -> StorageResult<Vec<String>> {
self.0.list_keys(key_type)
}
}

/// The engine clears a peer's confirmed-session state only once
/// `delete_session` returns `Ok`, so an `Err` must leave the session
/// where it was, not half deleted.
#[test]
fn a_failed_delete_session_leaves_the_session_in_place() {
let storage: Arc<dyn MlsStorage> = Arc::new(StampDeleteFails(InMemoryStorage::new()));
let (keys, address) = test_identity("alice");
seed_identity(&storage, &keys);
let alice = MlsManager::new(address.to_string(), storage).unwrap();
let bob = create_test_manager("bob");
let bob_kp = bob.generate_key_package().unwrap();
alice
.import_key_package(&addr("bob"), &bob_kp.key_package_data)
.unwrap();
alice.create_session(&addr("bob")).unwrap();

assert!(alice.delete_session(&addr("bob")).is_err());
assert!(alice.has_session(&addr("bob")).unwrap());
assert!(alice
.session_established_at_ms(&addr("bob"))
.unwrap()
.is_some());
}

#[test]
fn test_session_decrypt_rejects_spoofed_sender() {
let (alice, bob) = create_test_session();
Expand Down
26 changes: 26 additions & 0 deletions crates/offline-protocol-sealed/src/keypackage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,24 @@ pub struct KeyPackagePayload {
#[serde(default)]
pub svc_versions: Vec<u8>,

/// Delivery-acknowledgement statuses the sender reads (e.g. `[1]` for
/// [`ACK_UNDECRYPTABLE_V1`]). Absent on legacy nodes (`#[serde(default)]`
/// gives empty, meaning plain delivery ACKs only).
///
/// Like `ctrl_versions` this says what the sender *accepts*: a peer
/// advertising entry 1 settles an ACK carrying
/// `ack_status = "undecryptable"` as "not delivered" (resend once).
/// One without it would read that ACK as a delivery, so a receiver answers
/// an undecryptable frame from such a peer with nothing at all.
///
/// Trust boundary: a plaintext field like the lists above. Stripping it
/// returns the peer to unanswered undecryptable frames (the old
/// behaviour); forging it onto a legacy peer makes us send it ACKs it
/// reads as deliveries, which an attacker in that position could equally
/// forge directly. A feature negotiation, never a security control.
#[serde(default)]
pub ack_versions: Vec<u8>,

/// This install's Nostr public key (x-only, 64-char lowercase hex), so a
/// peer can seal Nostr gift wraps to a key only this install holds.
///
Expand Down Expand Up @@ -248,6 +266,11 @@ pub const CTRL_SIGN_V2: u8 = 2;
/// service layer, with the MLS-authenticated sender as the requester.
pub const SVC_SEALED_V1: u8 = 1;

/// Acknowledgement version advertised in [`KeyPackagePayload::ack_versions`]:
/// the sender reads `ack_status = "undecryptable"` on a delivery ACK as "the
/// recipient can never decrypt this frame" rather than as a delivery.
pub const ACK_UNDECRYPTABLE_V1: u8 = 1;

/// The conformance vectors for the key package payload.
///
/// The chapter these pin is `docs/spec/capability-negotiation.md`, and they pin
Expand Down Expand Up @@ -330,6 +353,7 @@ mod spec_vectors {
("data_versions", &got.data_versions),
("ctrl_versions", &got.ctrl_versions),
("svc_versions", &got.svc_versions),
("ack_versions", &got.ack_versions),
] {
assert_eq!(got, &u8s(&want[field]), "[{name}] {field}");
}
Expand Down Expand Up @@ -368,6 +392,7 @@ mod spec_vectors {
data_versions: Vec::new(),
ctrl_versions: Vec::new(),
svc_versions: Vec::new(),
ack_versions: Vec::new(),
nostr_pubkey: None,
};
let rendered = serde_json::to_string(&payload).expect("it serializes");
Expand Down Expand Up @@ -404,6 +429,7 @@ mod spec_vectors {
"data_versions",
"ctrl_versions",
"svc_versions",
"ack_versions",
"nostr_pubkey",
"session_reset",
] {
Expand Down
4 changes: 3 additions & 1 deletion crates/offline-protocol-sealed/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ pub use identity_assertion::{
encode_identity_assertion, parse_identity_assertion, IdentityAssertion, ED25519_SIGNATURE_LEN,
IDENTITY_ASSERTION_MIN_LEN,
};
pub use keypackage::{KeyPackagePayload, CTRL_SIGN_V2, MLS_ENVELOPE_COMPACT_V1, SVC_SEALED_V1};
pub use keypackage::{
KeyPackagePayload, ACK_UNDECRYPTABLE_V1, CTRL_SIGN_V2, MLS_ENVELOPE_COMPACT_V1, SVC_SEALED_V1,
};

#[cfg(all(test, feature = "std"))]
mod manifest_guard_tests {
Expand Down
Loading
Loading