offline_protocol/protocol/security.rs
1//! Control message signing, verification, and peer identity derivation.
2
3use super::storage::MAX_RESTORE_KEYS_PER_CATEGORY;
4use super::{
5 base64_decode, base64_encode, internal_prefixes, storage_keys, ControlGateOutcome,
6 ControlSigVersion, ControlVerification, EncryptionCapableEntry, InternalMessageResult,
7 OfflineProtocol, CTRL_PK_META_KEY, CTRL_SIG_META_KEY, DATA_PLANE_PREFIXES, INTERNAL_PREFIXES,
8 MAX_CONTROL_GATE_WARNED_PEERS, MAX_PLAINTEXT_RECEIVE_WARNED_PEERS, RELAY_ANSWER_PREFIXES,
9};
10use crate::events::{Event, SecurityWarningCode};
11use crate::{Error, Result};
12use chrono::Utc;
13use offline_protocol_core::{Address, Message, UserId};
14use offline_protocol_sealed::Freshness;
15use offline_protocol_transport::TransportType;
16use std::time::{Duration, Instant};
17use tracing::{debug, error, info, warn};
18
19/// Minimum gap between [`SecurityWarningCode::PushKeyPackagePoolExhausted`]
20/// emissions. Matches the suppression the Nostr slot-exhaustion and
21/// unauthorized-membership reports use, for the same reason: the condition
22/// persists, so an unsuppressed warning reports one cause many times.
23const PUSH_KEY_PACKAGE_WARNING_SUPPRESS_INTERVAL: Duration = Duration::from_secs(300);
24
25/// What [`OfflineProtocol::judge_control_frame_freshness`] concluded.
26///
27/// Private to this module: it is the gate's own vocabulary for a decision it
28/// then reports outward as `freshness_bound` plus an accept-or-drop.
29enum FreshnessVerdict {
30 /// Signed under the freshness-bound payload, inside the window.
31 Bound,
32 /// Authentic, with its age not established: signed under the older
33 /// payload, or under either one with the freshness check switched off.
34 ///
35 /// Accepted, and it carries **no timestamp onward**, which is the point.
36 /// What a directive that destroys state may then do is the dispatch site's
37 /// decision, not this one: a peer that has never proved the newer payload
38 /// keeps its reset, and a peer that has proved it does not. Either way
39 /// nothing here can move a replay mark, because there is no stamp this
40 /// verdict vouches for.
41 Unbound,
42 /// Refused. The caller drops the frame without acknowledging it.
43 Refused,
44}
45
46impl OfflineProtocol {
47 /// Builds the canonical signing payload a control frame is authenticated
48 /// over: `CTRL_SIGN_DOMAIN` followed by sender, message id, recipient and
49 /// content, each as `<4-byte big-endian length><utf-8 bytes>`, which makes
50 /// the encoding unambiguous regardless of field content (no
51 /// delimiter-collision risk).
52 ///
53 /// The construction itself lives in
54 /// [`offline_protocol_sealed::control_signing_payload`], with every other
55 /// signing domain in the protocol, so a leaf node signs and verifies the
56 /// same bytes. Both the signer and the verifier here call this one
57 /// function: a verifier that rebuilds the payload from its own copy of the
58 /// field order starts accepting forgeries the moment the two drift.
59 pub(crate) fn build_canonical_payload(message: &Message) -> Result<Vec<u8>> {
60 offline_protocol_sealed::control_signing_payload(message)
61 .map_err(|e| Error::Other(e.to_string()))
62 }
63
64 /// Builds the freshness-bound signing payload: the four fields above plus
65 /// the frame's timestamp, under `offline-ctrl-v2`.
66 ///
67 /// Lives in the sealed layer beside its v1 sibling for the same reason
68 /// that one does, and the reason is sharper here: a leaf node has to
69 /// reproduce these bytes from a different MLS implementation, and a
70 /// verifier whose idea of the payload drifts from the signer's does not
71 /// fail loudly, it fails as "this peer's signatures are all invalid".
72 pub(crate) fn build_canonical_payload_v2(message: &Message) -> Result<Vec<u8>> {
73 offline_protocol_sealed::control_signing_payload_v2(message)
74 .map_err(|e| Error::Other(e.to_string()))
75 }
76
77 /// Signs a control message by adding an Ed25519 signature and the sender's
78 /// public key to the message metadata.
79 ///
80 /// Returns `Ok(())` on success (or when MLS is not initialized — unsigned
81 /// messages are acceptable for backward compatibility with pre-MLS peers).
82 /// Returns `Err` when MLS is initialized but signing fails, so the caller
83 /// can decide whether to abort the send.
84 pub(super) fn sign_control_message(&self, message: &mut Message) -> Result<()> {
85 let mls = match self.mls_manager.as_ref() {
86 Some(m) => m,
87 None => {
88 debug!("MLS not initialized — sending unsigned control message");
89 return Ok(());
90 }
91 };
92 let manager = match mls.read() {
93 Ok(guard) => guard,
94 Err(e) => {
95 let reason = format!("MLS lock poisoned — cannot sign control message: {}", e);
96 error!(error = %e, "MLS lock poisoned — cannot sign control message");
97 return Err(Error::Other(reason));
98 }
99 };
100
101 let freshness_bound = self.signs_freshness_bound_control_to(message.recipient.as_str());
102 Self::sign_control_message_with(message, &manager, freshness_bound)
103 }
104
105 /// Whether a control frame addressed to `recipient` should carry the
106 /// freshness-bound signature.
107 ///
108 /// Signing v2 at a peer that cannot verify it makes every control frame we
109 /// send them fail their signature check, which reads on their side as an
110 /// attack rather than a version gap. So the newer payload is used only
111 /// where the recipient said it verifies one, on the one channel it has for
112 /// saying so.
113 ///
114 /// # First contact necessarily signs v1
115 ///
116 /// The first key package to a peer we have never met is signed under the
117 /// old payload, because their capabilities arrive *in their reply*. That
118 /// is inherent rather than a gap to close: nothing can know a stranger's
119 /// capabilities before meeting them. It converges in one round trip, and
120 /// the ratchet closes behind it — once a peer's v2 signature has verified
121 /// once, their v1 frames are refused, so the first-contact frame cannot be
122 /// replayed at us later.
123 ///
124 /// # Frames addressed to ourselves
125 ///
126 /// A relay hint is addressed to `local_id` and comes back to us if a
127 /// prefix-unaware relay echoes it, so the only possible verifier is this
128 /// node, whose capability is not in doubt.
129 pub(crate) fn signs_freshness_bound_control_to(&self, recipient: &str) -> bool {
130 recipient == self.local_id || self.peer_ctrl_freshness.contains(recipient)
131 }
132
133 /// Stamps `message` with an Ed25519 signature and public key from
134 /// `manager`'s identity.
135 ///
136 /// Split out from [`Self::sign_control_message`] so a caller that already
137 /// holds the signing identity — a test standing in for a peer's device —
138 /// can produce the same bytes the peer's own instance would, rather than
139 /// reimplementing the canonical payload and getting it subtly wrong.
140 ///
141 /// `freshness_bound` selects the payload: the one that covers the frame's
142 /// timestamp, or the one that does not. It is a parameter rather than a
143 /// lookup because this function has no `self` to look anything up on, and
144 /// making it guess would put the choice in two places.
145 pub(crate) fn sign_control_message_with(
146 message: &mut Message,
147 manager: &offline_protocol_mls::MlsManager,
148 freshness_bound: bool,
149 ) -> Result<()> {
150 let public_key = match manager.get_identity_public_key() {
151 Ok(pk) => pk,
152 Err(e) => {
153 let reason = format!("Failed to get identity public key: {}", e);
154 error!(error = %e, "Failed to get identity public key — cannot sign control message");
155 return Err(Error::Other(reason));
156 }
157 };
158 let canonical = if freshness_bound {
159 Self::build_canonical_payload_v2(message)?
160 } else {
161 Self::build_canonical_payload(message)?
162 };
163 let signature = match manager.sign_data(&canonical) {
164 Ok(sig) => sig,
165 Err(e) => {
166 let reason = format!("Failed to sign control message: {}", e);
167 error!(error = %e, "Failed to sign control message");
168 return Err(Error::Other(reason));
169 }
170 };
171
172 message
173 .metadata
174 .insert(CTRL_SIG_META_KEY.to_string(), base64_encode(&signature));
175 message
176 .metadata
177 .insert(CTRL_PK_META_KEY.to_string(), base64_encode(&public_key));
178 Ok(())
179 }
180
181 /// Verifies a control message's Ed25519 signature from metadata, and that
182 /// the key which signed it is the one the claimed sender's address names.
183 ///
184 /// Returns:
185 /// - `Ok(Verified(version))` — valid signature, and the signing key derives
186 /// to `sender`; `version` says which canonical payload it was over
187 /// - `Ok(Unsigned)` — no signature metadata at all
188 /// - `Err(..)` — signature invalid, sender/key derivation mismatch, or
189 /// malformed metadata (e.g. public key present without a signature,
190 /// or vice versa)
191 ///
192 /// # Why both payloads are tried
193 ///
194 /// A signature does not say which byte string it was made over, so a
195 /// verifier holding one candidate payload cannot distinguish "signed under
196 /// the other domain" from "forged". Trying the freshness-bound payload and
197 /// then the older one is what turns a version gap into a version answer.
198 /// The newer one goes first so the common case costs one verification, and
199 /// so that a frame which satisfies both — impossible, the domains differ —
200 /// would be read as the stronger claim rather than the weaker.
201 ///
202 /// The second verification is only reached by a frame that failed the
203 /// first, which is either a legacy peer or a forgery. Both are rare
204 /// relative to control traffic as a whole, and a control frame is not on
205 /// the message path, so the cost is one extra Ed25519 verification on a
206 /// path that already does a SHA-256 and an address parse.
207 pub(super) fn verify_control_message(
208 &mut self,
209 message: &Message,
210 ) -> Result<ControlVerification> {
211 let sig_b64 = match message.metadata.get(CTRL_SIG_META_KEY) {
212 Some(s) => s,
213 None => {
214 // If the public key metadata is present without a signature,
215 // treat this as a malformed message rather than unsigned.
216 if message.metadata.contains_key(CTRL_PK_META_KEY) {
217 return Err(Error::Other(
218 "Control message has public key but missing signature (malformed)"
219 .to_string(),
220 ));
221 }
222 // Truly unsigned — caller decides policy.
223 return Ok(ControlVerification::Unsigned);
224 }
225 };
226 let pk_b64 = match message.metadata.get(CTRL_PK_META_KEY) {
227 Some(s) => s,
228 None => {
229 return Err(Error::Other(
230 "Control message has signature but missing public key".to_string(),
231 ));
232 }
233 };
234
235 let signature = base64_decode(sig_b64)
236 .map_err(|e| Error::Other(format!("Invalid control signature encoding: {}", e)))?;
237 let public_key = base64_decode(pk_b64)
238 .map_err(|e| Error::Other(format!("Invalid control public key encoding: {}", e)))?;
239
240 // Verify the Ed25519 signature over a length-prefixed canonical
241 // payload. The freshness-bound one binds sender, message ID,
242 // recipient, content and the frame's timestamp; the older one binds
243 // every field but the last.
244 let verifies = |payload: &[u8]| -> Result<bool> {
245 offline_protocol_mls::MlsManager::verify_signature(&public_key, payload, &signature)
246 .map_err(|e| Error::Other(format!("Signature verification error: {}", e)))
247 };
248
249 let version = if verifies(&Self::build_canonical_payload_v2(message)?)? {
250 ControlSigVersion::V2
251 } else if verifies(&Self::build_canonical_payload(message)?)? {
252 ControlSigVersion::V1
253 } else {
254 return Err(Error::Other(
255 "Control message signature verification failed".to_string(),
256 ));
257 };
258
259 // The claim is now *proved*, not pinned: the address the frame claims
260 // to come from is the hash of the key that just signed it, or it is
261 // not this peer's frame.
262 self.verify_sender_derivation(message.sender.as_str(), &public_key)?;
263
264 // Reaching here means an Ed25519 signature over the canonical payload
265 // verified against a key that derives to the claimed sender, and that
266 // key *is* the peer's MLS identity key (`get_identity_public_key`
267 // returns the credential's `signature_key`). So this is proof the peer
268 // runs MLS.
269 self.record_encryption_capable(message.sender.as_str());
270
271 // And, when the signature was over the freshness-bound payload, that
272 // this peer can produce one. This is the ratchet: from here on their
273 // frames under the older payload are refused, which is what stops the
274 // whole check being side-stepped with a recording made before they
275 // upgraded.
276 if version == ControlSigVersion::V2 {
277 self.record_control_freshness_proved(message.sender.as_str());
278 }
279 Ok(ControlVerification::Verified(version))
280 }
281
282 /// Requires `public_key` to derive to the address in `sender`.
283 ///
284 /// This one function is what the TOFU pin store used to be. The store held
285 /// a key per peer so a later frame could be compared against the first one
286 /// seen; an address makes that comparison unnecessary, because the address
287 /// already *is* `bech32m(0x01 ‖ SHA-256(key)[..20])`. Impersonation stops
288 /// costing "be the first to claim the name" and starts costing a 160-bit
289 /// second preimage (~2^160). Note that this is the *targeted* figure — the
290 /// birthday bound on the same truncation is ~2^80, which buys two keys
291 /// under one address rather than a specific peer's address; see
292 /// `Address::HASH_LEN` for why that trade was taken.
293 ///
294 /// # Why an unparseable sender is rejected
295 ///
296 /// A sender that is not an address has no derivation to check, and the
297 /// tempting answer — pass, there is nothing to compare — is the whole
298 /// bypass: an attacker would claim a nickname and skip the gate. So a
299 /// control frame whose sender is not an address is refused outright. This
300 /// is also what makes the check unconditional in the sense that matters:
301 /// there is no input for which it declines to run.
302 fn verify_sender_derivation(&mut self, sender: &str, public_key: &[u8]) -> Result<()> {
303 let claimed = match sender.parse::<Address>() {
304 Ok(claimed) => claimed,
305 Err(e) => {
306 // Reported like the mismatch arm below, and for the same
307 // reason: this is the other half of "the claim cannot be bound
308 // to the key that signed it". The event stays identifier-free
309 // — `sender` here is an unparseable, attacker-chosen string, so
310 // rendering it would put arbitrary wire text on the sink. The
311 // rendered error below reaches the device log through the
312 // caller's `warn!`.
313 self.warn_control_gate_rejection(
314 sender,
315 SecurityWarningCode::SenderAddressMismatch,
316 "Control message sender is not an address, so no signing key can be bound \
317 to the claim",
318 );
319 return Err(Error::Other(format!(
320 "Control message sender '{}' is not an address: {}",
321 sender, e
322 )));
323 }
324 };
325
326 let derived = offline_protocol_mls::MlsManager::derive_address(public_key)
327 .map_err(|e| Error::Other(format!("Cannot derive an address from the key: {}", e)))?;
328
329 if derived != claimed {
330 warn!(
331 sender = %sender,
332 derived = %derived,
333 "Control message signing key does not derive to the claimed sender"
334 );
335 self.warn_control_gate_rejection(
336 sender,
337 SecurityWarningCode::SenderAddressMismatch,
338 "Signing key does not derive to the claimed sender address (impersonation attempt)",
339 );
340 return Err(Error::Other(format!(
341 "Sender address mismatch: '{}' claimed, key derives to '{}'",
342 sender, derived
343 )));
344 }
345 Ok(())
346 }
347
348 /// Marks `peer_id` encryption-capable, in memory and durably.
349 ///
350 /// The durable half is not bookkeeping: `encryption_capable_peers` is what
351 /// keeps the plaintext-downgrade gate shut, and a session can be torn down
352 /// remotely, so without a record that outlives the session the next launch
353 /// would come up knowing nothing about a peer it had verified and re-open
354 /// the gate for them. The TOFU store used to be that record as a side
355 /// effect of holding pins; it is now the only thing this category does.
356 ///
357 /// # Why the write is gated on the in-memory mark
358 ///
359 /// `mark_encryption_capable` refuses past
360 /// [`MAX_ENCRYPTION_CAPABLE_PEERS`](super::MAX_ENCRYPTION_CAPABLE_PEERS),
361 /// and persisting regardless would leave the durable category unbounded
362 /// while the set it feeds is capped. That gap is reachable: minting an
363 /// identity that signs honestly as itself costs one Ed25519 keygen, so an
364 /// attacker can write records without limit. The damage is not the disk —
365 /// it is that `restore_encryption_capable_peers` reads only the first
366 /// [`MAX_RESTORE_KEYS_PER_CATEGORY`] keys the store lists, so a flooded
367 /// category can push a legitimate peer's record out of the restore window
368 /// and re-open the plaintext gate for them on the next launch. Writing only
369 /// what the capped set accepted holds the category under that cap, which is
370 /// what makes the restore walk's "durable records are first in line" claim
371 /// true rather than aspirational. The deleted TOFU store had this property
372 /// too — its store-full branch declined to pin *and* to persist.
373 ///
374 /// # Why the write is skipped once the record is known durable
375 ///
376 /// The record's presence *is* the fact; its `last_seen_ms` is diagnostic and
377 /// nothing reads it back as a policy input (see [`EncryptionCapableEntry`]).
378 /// Rewriting it on every verified frame would mean a synchronous credential-
379 /// store write per control message — on iOS and Android a Keychain/Keystore
380 /// round-trip — to refresh a field no decision depends on, and the gated
381 /// prefixes include the chatty ones (`__TYPING__`, `__READ_RECEIPT__`,
382 /// `__PRESENCE__`). The pin store this replaced did rewrite per frame, but
383 /// it had to: the timestamp drove its LRU eviction. Nothing evicts here.
384 ///
385 /// The skip keys on [`Self::encryption_capable_persisted`] — writes that
386 /// actually landed — rather than on set membership, because
387 /// [`Self::persist_encryption_capable`] is best-effort. Keying on the
388 /// in-memory set would let one transient storage failure strand the peer
389 /// unwritten for the rest of the process, which is precisely the state that
390 /// re-opens the plaintext gate for them on the next launch. Keyed this way
391 /// a failed write is simply retried on their next verified frame.
392 ///
393 /// # The mark is never skipped, only the write
394 ///
395 /// Note the ordering: `mark_encryption_capable` runs *before* the cache is
396 /// consulted. Returning early on a cache hit would skip the mark too, and
397 /// the two can legitimately disagree — a failed `initialize_mls` rolls
398 /// `encryption_capable_peers` back to its pre-restore snapshot while the
399 /// cache still remembers the records restore read off disk. A peer in that
400 /// window would then be waved through here without ever entering the set
401 /// that holds the plaintext gate shut for them: the exact fail-open this
402 /// field exists to prevent, bought for a saved write.
403 pub(super) fn record_encryption_capable(&mut self, peer_id: &str) {
404 if !self.mark_encryption_capable(peer_id) {
405 return;
406 }
407 if self.encryption_capable_persisted.contains(peer_id) {
408 return;
409 }
410 if self.persist_encryption_capable(peer_id) {
411 self.encryption_capable_persisted
412 .insert(peer_id.to_string());
413 }
414 }
415
416 /// Security gate for control messages. Validates transport-level sender
417 /// identity and cryptographic signature before allowing the message to
418 /// proceed through `process_internal_message`.
419 ///
420 /// Returns [`ControlGateOutcome::Rejected`] to drop the message (without
421 /// sending a delivery ACK), or [`ControlGateOutcome::Proceed`] to allow it
422 /// through — carrying whether the frame was actually signed, so a handler
423 /// that treats a payload field as authenticated can check rather than
424 /// assume. See [`ControlGateOutcome::Proceed`] for what that bit does and
425 /// does not mean.
426 pub(super) fn security_gate_control_message(
427 &mut self,
428 message: &Message,
429 arrival_transport: Option<TransportType>,
430 ) -> ControlGateOutcome {
431 let content = &message.content;
432 let sender = message.sender.as_str();
433
434 if !Self::is_security_gated_prefix(content) {
435 // Not a security-gated control message — no gate needed, and
436 // nothing verified, so `signed` stays false.
437 return ControlGateOutcome::Proceed {
438 signed: false,
439 freshness_bound: false,
440 };
441 }
442
443 // Transport-level identity check
444 if !self.validate_transport_sender(message) {
445 warn!(
446 sender = %sender,
447 message_id = %message.id,
448 "Dropping control message: sender/transport identity mismatch or missing"
449 );
450 self.warn_control_gate_rejection(
451 sender,
452 SecurityWarningCode::TransportIdentityMismatch,
453 "Control message sender does not match transport peer identity",
454 );
455 return ControlGateOutcome::Rejected(InternalMessageResult::SecurityRejected);
456 }
457
458 // Log telemetry when transport identity is absent (passed best-effort).
459 // This is not emitted as a SecurityWarning event because relayed/forwarded
460 // messages routinely lack transport_peer_id and flooding the event stream
461 // would desensitize operators to real warnings.
462 if message.transport_peer_id().is_none() {
463 debug!(
464 sender = %sender,
465 message_id = %message.id,
466 require_identity = %self.config.security.require_transport_identity,
467 "Control message passed without transport peer identity (best-effort)"
468 );
469 }
470
471 // Cryptographic signature check
472 let (signed, freshness_bound) = match self.verify_control_message(message) {
473 Ok(ControlVerification::Verified(version)) => {
474 match self.judge_control_frame_freshness(message, version) {
475 FreshnessVerdict::Bound => (true, true),
476 FreshnessVerdict::Unbound => (true, false),
477 FreshnessVerdict::Refused => {
478 return ControlGateOutcome::Rejected(
479 InternalMessageResult::SecurityRejected,
480 )
481 }
482 }
483 }
484 Ok(ControlVerification::Unsigned)
485 if Self::is_unsignable_relay_answer(message, arrival_transport) =>
486 {
487 // A relay-originated answer, which no peer signed because no
488 // peer sent it. See `RELAY_ANSWER_PREFIXES` for why this cannot
489 // be signature-gated and what does and does not protect it.
490 debug!(
491 sender = %sender,
492 message_id = %message.id,
493 "Accepting unsigned relay-originated control frame (no peer signs these)"
494 );
495 // Nothing signed it, so nothing bound its age either. The
496 // relay-answer forgery residual (threat model R1) is unchanged
497 // by any of this: a frame with no signer cannot be given one.
498 (false, false)
499 }
500 Ok(ControlVerification::Unsigned) => {
501 // Unsigned control traffic is refused, unconditionally.
502 //
503 // This used to be a two-part policy: reject if the sender had a
504 // pin (a known-signed peer going quiet is a downgrade), else
505 // reject only under `require_transport_identity`. Both halves
506 // existed because an unsigned frame from a peer we knew nothing
507 // about was, at the time, indistinguishable from a legacy peer
508 // — there was no way to check an identity claim without prior
509 // contact, so refusing would have meant refusing first contact.
510 //
511 // Derived addresses remove that excuse: a signature is now
512 // *self-verifying* against the sender's own id, so a peer that
513 // will not sign is a peer making an unprovable claim, whether
514 // or not we have met them. Leaving any accepting path here
515 // would also leave the forged-`hop_count` bypass open — a
516 // spoofer who sets `hop_count > 0` skips the transport-identity
517 // strict match and lands exactly on this branch, and would then
518 // impersonate any peer without committing a signing key at all.
519 warn!(
520 sender = %sender,
521 message_id = %message.id,
522 "Dropping unsigned control message: control traffic must be signed"
523 );
524 self.warn_control_gate_rejection(
525 sender,
526 SecurityWarningCode::UnsignedControlRejected,
527 "Unsigned control message rejected: control traffic must carry a signature \
528 from the key its sender address derives from",
529 );
530 return ControlGateOutcome::Rejected(InternalMessageResult::SecurityRejected);
531 }
532 Err(err) => {
533 // Signature invalid, derivation mismatch, or malformed metadata — drop
534 warn!(
535 sender = %sender,
536 message_id = %message.id,
537 error = %err,
538 "Dropping control message: signature verification failed"
539 );
540 // Classified, not rendered. `err` names both the claimed sender
541 // and — on the derivation-mismatch arm — the address the
542 // signing key really derives to, and a `SecurityWarning`'s
543 // `reason` is shipped verbatim by the telemetry scrubber (only
544 // `peer_id`, the *claimed* sender, is hashed). Rendering it
545 // here would hand the sink the pair that de-anonymizes that
546 // hash, beside the deliberately identifier-free event
547 // `verify_sender_derivation` already emitted for the same
548 // refusal. The full error is in the `warn!` above.
549 self.warn_control_gate_rejection(
550 sender,
551 SecurityWarningCode::ControlSignatureInvalid,
552 "Control message rejected: its signature, signing metadata, or \
553 sender/key derivation failed verification",
554 );
555 return ControlGateOutcome::Rejected(InternalMessageResult::SecurityRejected);
556 }
557 };
558
559 ControlGateOutcome::Proceed {
560 signed,
561 freshness_bound,
562 }
563 }
564
565 /// Decides what a verified control frame's age means for it.
566 ///
567 /// Three outcomes, because a verified signature now carries three
568 /// different amounts of weight:
569 ///
570 /// - [`FreshnessVerdict::Bound`]: signed under the freshness-bound payload
571 /// and inside the window, so it is provably not a recording. Only such a
572 /// frame may carry a directive that destroys state.
573 /// - [`FreshnessVerdict::Unbound`]: authentic, with its age not
574 /// established. Everything a control frame did before this existed, it
575 /// still does, and no timestamp travels with it. Whether it may tear a
576 /// session down is decided downstream, by the ratchet and the switch,
577 /// because a peer that has proved nothing must keep its resets or it can
578 /// never heal a forked session.
579 /// - [`FreshnessVerdict::Refused`]: the frame states an age this node will
580 /// not accept, or comes under the older payload from a peer that has
581 /// proved it can produce the newer one.
582 ///
583 /// # The key-package escape
584 ///
585 /// A peer held to the newer payload is refused *except* on
586 /// `__MLS_KEY_PKG__`, which is admitted and then has its `session_reset`
587 /// ignored (the `Unbound` verdict is what conveys that downstream). The
588 /// escape exists because the ratchet would otherwise be a trap with no
589 /// way out: a peer whose record of *us* was lost signs the older payload,
590 /// because it no longer knows we accept the newer one, and is refused on
591 /// the very frame that would have told it. Since a key package is also the
592 /// only frame that re-teaches capabilities, refusing it makes the state
593 /// permanent. See the escape itself for what actually loses that record —
594 /// not a reinstall, which arrives as a different address entirely.
595 ///
596 /// Admitting it costs nothing an attacker wants: a key package with its
597 /// reset ignored advertises capabilities, which is the thing this protocol
598 /// treats as unauthenticated hint data everywhere else, and its destructive
599 /// half stays shut.
600 fn judge_control_frame_freshness(
601 &mut self,
602 message: &Message,
603 version: ControlSigVersion,
604 ) -> FreshnessVerdict {
605 let sender = message.sender.as_str();
606
607 if !self.config.security.control_freshness_enforced {
608 // The switch returns this node to its pre-403 behaviour exactly:
609 // signatures verified, no frame refused for its age, and a reset
610 // honoured on any verified frame.
611 //
612 // `Unbound` rather than `Bound`, and the difference is not
613 // cosmetic. What keeps resets working with the switch off is the
614 // dispatch site's own `!control_freshness_enforced` clause, not
615 // this verdict; reporting `Bound` here would additionally hand the
616 // handler the frame's timestamp as though the signature covered
617 // it, and on a v1 frame it does not. That is the failure this
618 // change calls worse than the replay it fixes: the stamp is
619 // metadata anyone can rewrite, so one captured v1 reset frame
620 // parks the peer's durable high-water mark at `i64::MAX` and
621 // denies that peer every future reset, across restarts and across
622 // the switch being turned back on.
623 //
624 // A clock is the reason this switch exists, so the honest reading
625 // of "the operator switched the check off" is that nobody looked,
626 // which is what `Unbound` says. Pre-403 recorded no mark at all,
627 // and with nothing established there is nothing to record.
628 return FreshnessVerdict::Unbound;
629 }
630
631 match version {
632 ControlSigVersion::V2 => {
633 let verdict = offline_protocol_sealed::control_frame_freshness(
634 message.timestamp.as_millis(),
635 Utc::now().timestamp_millis(),
636 offline_protocol_sealed::CTRL_FRESHNESS_PAST_MS,
637 offline_protocol_sealed::CTRL_FRESHNESS_FUTURE_MS,
638 );
639 match verdict {
640 Freshness::Fresh => FreshnessVerdict::Bound,
641 Freshness::Stale { age_ms } => {
642 warn!(
643 sender = %sender,
644 message_id = %message.id,
645 age_ms,
646 "Dropping control message: stamped further in the past than the \
647 freshness window allows"
648 );
649 self.warn_control_gate_rejection(
650 sender,
651 SecurityWarningCode::StaleControlFrame,
652 "Control message refused as stale: its signed timestamp is older \
653 than this node accepts, which is what a replayed capture looks like",
654 );
655 FreshnessVerdict::Refused
656 }
657 Freshness::FromTheFuture { skew_ms } => {
658 warn!(
659 sender = %sender,
660 message_id = %message.id,
661 skew_ms,
662 "Dropping control message: stamped ahead of this device's clock by \
663 more than the freshness window allows"
664 );
665 self.warn_control_gate_rejection(
666 sender,
667 SecurityWarningCode::StaleControlFrame,
668 "Control message refused for clock skew: its signed timestamp is \
669 further ahead of this device's clock than is allowed. If this \
670 device's own clock is wrong, every peer looks like this",
671 );
672 FreshnessVerdict::Refused
673 }
674 }
675 }
676 ControlSigVersion::V1 => {
677 if !self.signs_freshness_bound_control(sender) {
678 // A peer that has never proved otherwise. Legacy, and
679 // accepted exactly as before — refusing here would refuse
680 // first contact with every install that has not upgraded.
681 return FreshnessVerdict::Unbound;
682 }
683 if message.content.starts_with(internal_prefixes::KEY_PACKAGE) {
684 debug!(
685 sender = %sender,
686 message_id = %message.id,
687 "Admitting a key package under the older payload from a peer held to the \
688 newer one; its session reset will be ignored"
689 );
690 // Clearing this is what makes the escape actually escape:
691 // the reciprocal send in `handle_key_package_message` is
692 // skipped for a peer we have already sent to, so without
693 // it the peer never receives the advertisement that would
694 // teach it to sign the newer payload again.
695 //
696 // The case it heals is capability-record *loss*, not a
697 // reinstall: an address is the hash of an identity key, so
698 // a peer that reinstalls arrives as a different peer
699 // entirely. What produces a peer at a known address whose
700 // record we no longer hold is eviction under
701 // `MAX_PENDING_KEY_PACKAGES` pressure, a restore that lost
702 // the capability category, or a storage failure on it.
703 //
704 // Replaying a captured v1 key package therefore costs us
705 // one key-package send. Bounded twice over: the receive
706 // deduplicator refuses an exact repeat for an hour, and
707 // `take_push_key_package` re-hands that peer's existing
708 // package rather than minting a fresh one, so nothing
709 // accumulates.
710 self.key_package_sent_to.remove(sender);
711 return FreshnessVerdict::Unbound;
712 }
713 warn!(
714 sender = %sender,
715 message_id = %message.id,
716 "Dropping control message: peer has proved it signs the freshness-bound \
717 payload and this frame does not carry one"
718 );
719 self.warn_control_gate_rejection(
720 sender,
721 SecurityWarningCode::StaleControlFrame,
722 "Control message refused as a downgrade: this peer has proved it signs the \
723 payload that states freshness, so one that does not is either a recording \
724 or a stripped signature",
725 );
726 FreshnessVerdict::Refused
727 }
728 }
729 }
730
731 /// Whether this frame is a relay answer that structurally cannot be signed.
732 ///
733 /// All three conditions are required, and the two beyond the prefix are what
734 /// keep this from being a hole in the peer-to-peer path:
735 ///
736 /// - the prefix is one the relay server originates
737 /// ([`RELAY_ANSWER_PREFIXES`]);
738 /// - it arrived on the Internet transport, where the relay ingest lives;
739 /// - it carries no transport peer identity, which is what a locally
740 /// synthesized answer looks like — a real peer's frame on this transport
741 /// is attributed by the relay and would fail this.
742 ///
743 /// A peer sending one of these prefixes over the mesh, or over the relay
744 /// with an attributed identity, is therefore still required to sign it.
745 fn is_unsignable_relay_answer(
746 message: &Message,
747 arrival_transport: Option<TransportType>,
748 ) -> bool {
749 arrival_transport == Some(TransportType::Internet)
750 && message.transport_peer_id().is_none()
751 && RELAY_ANSWER_PREFIXES
752 .iter()
753 .any(|p| message.content.starts_with(p))
754 }
755
756 /// Validates that the claimed `message.sender` matches the transport-level
757 /// peer identity, if available. Returns `true` if validated or if no
758 /// transport identity is available (best-effort). Returns `false` if
759 /// there is a mismatch on a frame claiming direct origin.
760 ///
761 /// # Hop-count rule
762 ///
763 /// The strict match applies only to frames with `hop_count == 0` — the
764 /// frame's claim that the carrying peer *is* its origin. A frame with
765 /// `hop_count > 0` was mesh-relayed: the transport identity names the
766 /// nearest carrier (the relaying peer), not the origin in
767 /// `message.sender`, so a mismatch is expected and carries no spoofing
768 /// signal. Those frames fall back to the signature + derivation gate.
769 ///
770 /// A spoofer can forge `hop_count > 0` to skip this check, and that is now
771 /// uninteresting: the forged-hop path lands on the same signature +
772 /// derivation gate every other control frame passes, which refuses an
773 /// unsigned frame outright and refuses a signed one whose key does not
774 /// derive to the claimed sender. There is no configuration in which
775 /// skipping this check buys an attacker anything, because this check is no
776 /// longer what establishes identity — it only cross-checks an identity the
777 /// signature has already proved.
778 ///
779 /// That is also why `require_transport_identity` keeps its `false` default
780 /// rather than flipping with the rest of this work. Its remaining job is
781 /// the `None` branch below, and demanding transport identity there costs
782 /// real delivery for no authenticity: Nostr frames carry none by design
783 /// (the Nostr pubkey is not the protocol id — see
784 /// `nostr_message_received_inner`), and neither do relay frames that arrive
785 /// without a named sender. Turning it on rejects every control message on
786 /// those paths. Deleting the field belongs with the wider API sweep; until
787 /// then it is a hardening knob for deployments that run neither transport.
788 ///
789 /// # Relay / mesh forwarding
790 ///
791 /// Forwarded messages re-created via [`send_internal_message`] →
792 /// [`create_message`] have `transport_peer_id: None`, and because
793 /// `transport_peer_id` is `#[serde(skip)]` it is also `None` after
794 /// deserialization unless the receiving transport attaches an
795 /// authenticated identity (Internet relay, Reticulum). Frames relayed
796 /// verbatim by `try_relay_message` keep their original `sender` but
797 /// always arrive with `hop_count >= 1` (the relayer increments before
798 /// re-sending), so the hop-count rule above prevents this check from
799 /// rejecting them.
800 pub(super) fn validate_transport_sender(&self, message: &Message) -> bool {
801 match message.transport_peer_id() {
802 Some(transport_peer) => {
803 if message.hop_count.value() > 0 {
804 debug!(
805 claimed_sender = %message.sender,
806 transport_peer = %transport_peer,
807 hop_count = message.hop_count.value(),
808 message_id = %message.id,
809 "Relayed control message: transport peer is the carrier, not the origin; skipping strict match"
810 );
811 } else if message.sender.as_str() != transport_peer {
812 warn!(
813 claimed_sender = %message.sender,
814 transport_peer = %transport_peer,
815 message_id = %message.id,
816 "Sender identity mismatch: claimed sender does not match transport peer"
817 );
818 return false;
819 }
820 }
821 None => {
822 if self.config.security.require_transport_identity {
823 warn!(
824 claimed_sender = %message.sender,
825 message_id = %message.id,
826 "Rejecting control message without transport peer identity \
827 (require_transport_identity=true)"
828 );
829 return false;
830 }
831 }
832 }
833 true
834 }
835
836 /// Emits a `SecurityWarning` event for the given peer.
837 pub(super) fn emit_security_warning(
838 &self,
839 peer_id: &str,
840 reason_code: SecurityWarningCode,
841 reason: impl Into<String>,
842 ) {
843 self.emit_event(Event::security_warning(
844 peer_id.to_string(),
845 reason_code,
846 reason.into(),
847 ));
848 }
849
850 /// The bit [`Self::warn_control_gate_rejection`] tracks for `code`.
851 ///
852 /// Zero for anything the control gate does not emit, which reads as "no bit
853 /// to suppress on" and lets such a code through unthrottled — the throttle
854 /// is deliberately scoped to the gate's own rejections rather than to
855 /// security warnings in general.
856 fn control_gate_warning_bit(code: SecurityWarningCode) -> u8 {
857 match code {
858 SecurityWarningCode::TransportIdentityMismatch => 1 << 0,
859 SecurityWarningCode::ControlSignatureInvalid => 1 << 1,
860 SecurityWarningCode::UnsignedControlRejected => 1 << 2,
861 SecurityWarningCode::SenderAddressMismatch => 1 << 3,
862 SecurityWarningCode::StaleControlFrame => 1 << 4,
863 _ => 0,
864 }
865 }
866
867 /// Emits a control-gate rejection warning for `peer_id`, at most once per
868 /// peer per reason code (tracked in a bounded map that resets at
869 /// [`MAX_CONTROL_GATE_WARNED_PEERS`], so a peer may re-warn after a
870 /// forged-sender flood).
871 ///
872 /// Every one of these codes is reachable from an unauthenticated frame
873 /// naming an attacker-chosen sender — a gate rejection is by definition a
874 /// frame that proved nothing — so emitting per frame hands an off-path
875 /// injector a way to bury the signal that matters
876 /// ([`SecurityWarningCode::SenderAddressMismatch`], which per its own docs
877 /// has no benign reading) under noise. The per-rejection `warn!` logs stay
878 /// at the call sites; this throttles only the app-facing event.
879 ///
880 /// Suppression is per code, not per peer, so a peer that first trips one
881 /// rejection still surfaces a later, different one. A single frame can
882 /// legitimately report two codes — a derivation mismatch reports the
883 /// specific `SenderAddressMismatch` and then the gate's general
884 /// `ControlSignatureInvalid` — and both are bounded to one event each.
885 pub(super) fn warn_control_gate_rejection(
886 &mut self,
887 peer_id: &str,
888 reason_code: SecurityWarningCode,
889 reason: impl Into<String>,
890 ) {
891 let bit = Self::control_gate_warning_bit(reason_code);
892 if bit != 0 {
893 if self
894 .control_gate_warned
895 .get(peer_id)
896 .is_some_and(|mask| mask & bit != 0)
897 {
898 return;
899 }
900 // The keys are attacker-controlled, so the map resets at capacity
901 // rather than growing without bound — same trade as
902 // `warn_plaintext_receive_rejected`.
903 if self.control_gate_warned.len() >= MAX_CONTROL_GATE_WARNED_PEERS
904 && !self.control_gate_warned.contains_key(peer_id)
905 {
906 self.control_gate_warned.clear();
907 }
908 *self
909 .control_gate_warned
910 .entry(peer_id.to_string())
911 .or_default() |= bit;
912 }
913 self.emit_security_warning(peer_id, reason_code, reason);
914 }
915
916 /// Emits a [`SecurityWarningCode::PlaintextSend`] warning for `peer_id`,
917 /// at most once per peer per protocol instance. Called from the outbound
918 /// plaintext fall-throughs, which are reachable only under the explicit
919 /// `require_encryption = false` opt-out — the warning keeps cleartext
920 /// flows visible without emitting per message.
921 pub(super) fn warn_plaintext_send(&mut self, peer_id: &str) {
922 if self.plaintext_send_warned.insert(peer_id.to_string()) {
923 warn!(
924 recipient = %peer_id,
925 "Outbound message sent as plaintext (encryption disabled or MLS \
926 uninitialized, require_encryption=false)"
927 );
928 self.emit_security_warning(
929 peer_id,
930 SecurityWarningCode::PlaintextSend,
931 "Outbound message sent as plaintext: encryption is disabled or MLS \
932 is not initialized, and require_encryption is false",
933 );
934 }
935 }
936
937 /// Emits a [`SecurityWarningCode::PushKeyPackagePoolExhausted`] warning,
938 /// at most once per [`PUSH_KEY_PACKAGE_WARNING_SUPPRESS_INTERVAL`].
939 ///
940 /// The condition — the per-peer key-package pool at its ceiling, so this
941 /// advertisement reuses a package another peer already holds — persists
942 /// until packages are consumed or expire, and every push meanwhile would
943 /// otherwise emit. Suppressed on time rather than per peer because the
944 /// interesting fact is the pool's state, not which peer happened to ask.
945 pub(super) fn warn_push_key_package_pool_exhausted(&mut self, peer_id: &str) {
946 let now = Instant::now();
947 if let Some(last) = self.last_push_key_package_warning {
948 if now.duration_since(last) < PUSH_KEY_PACKAGE_WARNING_SUPPRESS_INTERVAL {
949 return;
950 }
951 }
952 self.last_push_key_package_warning = Some(now);
953 warn!(
954 peer_id = %peer_id,
955 "Key-package pool at capacity; this peer shares an init key with another"
956 );
957 self.emit_security_warning(
958 peer_id,
959 SecurityWarningCode::PushKeyPackagePoolExhausted,
960 "key-package pool at capacity: this peer was advertised an init key \
961 another peer also holds, weakening forward secrecy at session \
962 establishment until packages are consumed or expire",
963 );
964 }
965
966 /// Emits a [`SecurityWarningCode::PlaintextReceiveRejected`] warning for
967 /// `peer_id`, at most once per peer (tracked in a bounded set that resets
968 /// at [`MAX_PLAINTEXT_RECEIVE_WARNED_PEERS`], so a peer may re-warn after
969 /// a forged-sender flood). Called from the inbound plaintext policy gate
970 /// ([`Self::accept_plaintext_content`]) when cleartext text or legacy
971 /// media is dropped — the per-rejection `warn!` logs live at the call
972 /// sites; this keeps the event stream from flooding when a legacy or
973 /// malicious peer keeps sending.
974 pub(super) fn warn_plaintext_receive_rejected(&mut self, peer_id: &str, detail: &str) {
975 if self.plaintext_receive_warned.contains(peer_id) {
976 return;
977 }
978 // The keys are attacker-controlled (wire-claimed sender ids), so the
979 // set resets at capacity instead of growing without bound: a forged-
980 // sender flood degrades the throttle to once-per-peer-per-generation
981 // while memory stays capped.
982 if self.plaintext_receive_warned.len() >= MAX_PLAINTEXT_RECEIVE_WARNED_PEERS {
983 self.plaintext_receive_warned.clear();
984 }
985 self.plaintext_receive_warned.insert(peer_id.to_string());
986 self.emit_security_warning(
987 peer_id,
988 SecurityWarningCode::PlaintextReceiveRejected,
989 detail,
990 );
991 }
992
993 /// Verifies the relay's `AddressDeclared` acknowledgement against the
994 /// address this node actually holds.
995 ///
996 /// This is the lockstep assertion for the relay leg of addressing. The
997 /// bridge declares [`Self::local_address`] and signs a proof over a
998 /// per-connection challenge; the relay verifies that the declared address
999 /// derives from the signing key and echoes back what it bound. Agreement
1000 /// means every frame the relay forwards from here on is attributed to the
1001 /// same identity the core stamps into `Message.sender`, which is what
1002 /// `validate_transport_sender` strict-matches on the receiving side.
1003 ///
1004 /// A disagreement is reported, never acted on: see
1005 /// [`SecurityWarningCode::RelayAddressBindingMismatch`] for why tearing the
1006 /// connection down would buy nothing. Callers pass the echoed address
1007 /// verbatim — an empty or malformed one simply cannot match and is reported
1008 /// like any other mismatch.
1009 ///
1010 /// Reached only through the FFI's dedicated entry point, not through
1011 /// message-plane injection, so a notification payload cannot synthesize it.
1012 pub fn on_relay_address_declared(&self, declared: &str) {
1013 match self.local_address() {
1014 Some(local) if local == declared => {
1015 info!(
1016 address = %declared,
1017 "Relay bound this connection to our address; frames are attributed by address"
1018 );
1019 }
1020 Some(local) => {
1021 warn!(
1022 declared = %declared,
1023 local = %local,
1024 "Relay acknowledged an address that is not ours"
1025 );
1026 // `declared` is this event's `peer_id`, where the scrubber
1027 // hashes it; interpolating it into `reason` — which is shipped
1028 // verbatim — would undo that hashing inside the same record.
1029 // The raw pair is in the `warn!` above.
1030 self.emit_security_warning(
1031 declared,
1032 SecurityWarningCode::RelayAddressBindingMismatch,
1033 "relay bound this connection to an address that is not this device's: \
1034 its frames are attributed to an identity we cannot prove, so receivers \
1035 will reject our security-gated control traffic",
1036 );
1037 }
1038 None => {
1039 // The bridge does not declare before an identity exists, so an
1040 // acknowledgement here answers a declaration this node never
1041 // made. Same disposition, distinct text: nothing local can be
1042 // compared, which is itself the finding.
1043 warn!(
1044 declared = %declared,
1045 "Relay acknowledged an address declaration this node never made"
1046 );
1047 self.emit_security_warning(
1048 declared,
1049 SecurityWarningCode::RelayAddressBindingMismatch,
1050 "relay bound this connection to an address while this device has no \
1051 established identity to declare: no declaration was sent from here",
1052 );
1053 }
1054 }
1055 }
1056
1057 /// Records the relay refusing this connection's address declaration.
1058 ///
1059 /// Non-fatal by contract on both sides — the connection stays authenticated
1060 /// and keeps working in account-name space. What degrades is *new* MLS
1061 /// session establishment over the relay; see
1062 /// [`SecurityWarningCode::RelayAddressDeclarationRefused`]. `reason` is the
1063 /// relay's own text: opaque, remote-chosen, and logged rather than
1064 /// emitted — an event field never carries text a remote party wrote.
1065 ///
1066 /// Attributed to this node rather than to a peer (the failure is ours, and
1067 /// no peer is involved), following the same convention as the Nostr
1068 /// slot-exhaustion warning.
1069 pub fn on_relay_address_declaration_refused(&self, reason: &str) {
1070 warn!(
1071 reason = %reason,
1072 "Relay refused our address declaration; staying in account-name space"
1073 );
1074 // The relay's own wording does not travel onto the event: it is
1075 // remote-chosen text of arbitrary length and content, and this event's
1076 // `reason` is shipped verbatim to telemetry sinks. The code *is* the
1077 // classification; the wording stays in the `warn!` above.
1078 self.emit_security_warning(
1079 &self.local_id,
1080 SecurityWarningCode::RelayAddressDeclarationRefused,
1081 "relay refused this connection's address declaration: frames stay \
1082 attributed by account name, so new encrypted sessions cannot be \
1083 established over the relay until a later connection declares successfully",
1084 );
1085 }
1086
1087 /// Returns `true` if the message content starts with any internal prefix.
1088 /// Used for injection prevention on the public send APIs.
1089 pub(crate) fn is_internal_prefix(content: &str) -> bool {
1090 INTERNAL_PREFIXES.iter().any(|p| content.starts_with(p))
1091 }
1092
1093 /// Returns `true` if the message content starts with a control-plane
1094 /// prefix that requires security gate enforcement (transport identity +
1095 /// signature verification + sender derivation). Data-plane prefixes listed in
1096 /// `DATA_PLANE_PREFIXES` (e.g. `__MLS_ENC__`) are excluded because MLS
1097 /// provides its own authentication layer.
1098 pub(crate) fn is_security_gated_prefix(content: &str) -> bool {
1099 // A prefix is security-gated if it is an internal prefix AND not a
1100 // data-plane prefix. This derives the gated set from INTERNAL_PREFIXES
1101 // minus DATA_PLANE_PREFIXES, so any new internal prefix is automatically
1102 // gated unless explicitly excluded.
1103 Self::is_internal_prefix(content)
1104 && !DATA_PLANE_PREFIXES.iter().any(|p| content.starts_with(p))
1105 }
1106
1107 /// Whether `peer_id` has ever presented a control-frame signature over the
1108 /// freshness-bound payload.
1109 ///
1110 /// In memory rather than a storage read, exactly like
1111 /// [`Self::is_encryption_capable`]: it is consulted on the control path of
1112 /// every frame, and the durable record exists to survive a restart rather
1113 /// than to be read per frame.
1114 pub(crate) fn signs_freshness_bound_control(&self, peer_id: &str) -> bool {
1115 self.control_freshness_peers.contains(peer_id)
1116 }
1117
1118 /// Records that `peer_id` has proved it signs the freshness-bound payload.
1119 ///
1120 /// # Why this set needs no cap of its own
1121 ///
1122 /// It is a strict subset of the capped encryption-capable set, and the
1123 /// order of the two checks below is what makes that true rather than
1124 /// merely likely: a peer the capped set refused is not admitted here
1125 /// either. Inserting first and checking after would let a forged-sender
1126 /// flood grow this one without bound, and it is reachable by exactly the
1127 /// same flood, since minting an identity that signs honestly as itself
1128 /// costs one Ed25519 keygen.
1129 ///
1130 /// # Why refusal is safe here, unlike for encryption capability
1131 ///
1132 /// Failing to record leaves this peer able to send us the older payload,
1133 /// which is where every peer started. It loses an improvement rather than
1134 /// a protection, so the flood costs later peers the ratchet and never
1135 /// costs an earlier peer anything they had.
1136 pub(crate) fn record_control_freshness_proved(&mut self, peer_id: &str) {
1137 if !self.is_encryption_capable(peer_id) {
1138 return;
1139 }
1140 if !self.control_freshness_peers.insert(peer_id.to_string()) {
1141 return;
1142 }
1143 // Reached once per peer per process, on the transition. The durable
1144 // record is rewritten here rather than through
1145 // `record_encryption_capable`, whose elision cache deliberately skips
1146 // a peer whose record already exists — which is every peer that just
1147 // upgraded.
1148 self.persist_control_freshness_proved(peer_id);
1149 }
1150
1151 /// Rewrites `peer_id`'s durable capability record with the ratchet set.
1152 ///
1153 /// Best-effort, like the record it extends: a failed write costs the
1154 /// ratchet across a restart, and it is re-proved by that peer's next
1155 /// freshness-bound frame. That is the fail-open direction, and it is the
1156 /// right one here — the alternative, refusing the peer's traffic because
1157 /// we could not write a note about them, breaks a working pair over a
1158 /// storage error.
1159 fn persist_control_freshness_proved(&self, peer_id: &str) {
1160 let Some(storage) = &self.secure_storage else {
1161 return;
1162 };
1163 let entry = EncryptionCapableEntry {
1164 last_seen_ms: Utc::now().timestamp_millis(),
1165 ctrl_freshness_proved: true,
1166 last_reset_ms: self
1167 .control_reset_watermark
1168 .get(peer_id)
1169 .copied()
1170 .unwrap_or(0),
1171 };
1172 match serde_json::to_vec(&entry) {
1173 Ok(data) => {
1174 if let Err(e) =
1175 storage.store(storage_keys::ENCRYPTION_CAPABLE_PEERS, peer_id, &data)
1176 {
1177 warn!(peer_id = %peer_id, error = %e, "Failed to persist control-freshness ratchet");
1178 }
1179 }
1180 Err(e) => {
1181 warn!(peer_id = %peer_id, error = %e, "Failed to serialize control-freshness ratchet");
1182 }
1183 }
1184 }
1185
1186 /// Whether a session reset stamped `signed_ms` from `peer_id` is one this
1187 /// node has not already acted on.
1188 ///
1189 /// Strictly newer, not newer-or-equal: a frame is identified by its stamp
1190 /// here, so admitting an equal one admits the frame itself a second time.
1191 /// Two legitimate resets in the same millisecond would need a rekey ladder
1192 /// far faster than `REKEY_INTERVAL_SECS`, and if one ever happened the
1193 /// pair still converges — a refused reset shows up as the next decrypt
1194 /// failure, which drives a fresh one.
1195 pub(crate) fn reset_is_unspent(&self, peer_id: &str, signed_ms: i64) -> bool {
1196 self.control_reset_watermark
1197 .get(peer_id)
1198 .is_none_or(|seen| signed_ms > *seen)
1199 }
1200
1201 /// Records a session reset as spent, in memory and durably.
1202 ///
1203 /// **Call this before acting on the reset, never after.** The teardown it
1204 /// authorizes is followed by a fresh session, so a crash between the two
1205 /// leaves a frame that has already destroyed one session still able to
1206 /// destroy its replacement. Recording first costs, at worst, a reset that
1207 /// was recorded and not carried out, which the pair recovers from the same
1208 /// way it recovers from a lost frame: the next decrypt failure drives
1209 /// another.
1210 pub(crate) fn record_reset_spent(&mut self, peer_id: &str, signed_ms: i64) {
1211 // The same subset rule the ratchet uses, checked before the insert:
1212 // this map inherits the encryption-capable cap rather than needing one
1213 // of its own.
1214 if !self.is_encryption_capable(peer_id) {
1215 return;
1216 }
1217 self.control_reset_watermark
1218 .insert(peer_id.to_string(), signed_ms);
1219
1220 let Some(storage) = &self.secure_storage else {
1221 return;
1222 };
1223 let entry = EncryptionCapableEntry {
1224 last_seen_ms: Utc::now().timestamp_millis(),
1225 ctrl_freshness_proved: self.signs_freshness_bound_control(peer_id),
1226 last_reset_ms: signed_ms,
1227 };
1228 match serde_json::to_vec(&entry) {
1229 Ok(data) => {
1230 if let Err(e) =
1231 storage.store(storage_keys::ENCRYPTION_CAPABLE_PEERS, peer_id, &data)
1232 {
1233 warn!(peer_id = %peer_id, error = %e, "Failed to persist the spent-reset mark");
1234 }
1235 }
1236 Err(e) => {
1237 warn!(peer_id = %peer_id, error = %e, "Failed to serialize the spent-reset mark");
1238 }
1239 }
1240 }
1241
1242 /// Persists the durable "this peer runs MLS" record.
1243 ///
1244 /// Best-effort and idempotent: the in-memory set is already updated by the
1245 /// time this runs, so a storage failure costs the knowledge only across a
1246 /// restart — and costs it in the safe direction, since the peer re-proves
1247 /// capability on their next signed control message.
1248 ///
1249 /// Returns whether the record is now durable, which is what lets
1250 /// [`Self::record_encryption_capable`] skip the repeat write without
1251 /// stranding a peer whose write failed. A node with no secure storage
1252 /// answers `false` forever — correctly: nothing was persisted, and there is
1253 /// no cost to re-answering that on each frame.
1254 pub(super) fn persist_encryption_capable(&self, peer_id: &str) -> bool {
1255 let Some(storage) = &self.secure_storage else {
1256 return false;
1257 };
1258 let entry = EncryptionCapableEntry {
1259 last_seen_ms: Utc::now().timestamp_millis(),
1260 // Carried forward rather than defaulted. This path writes the
1261 // whole record, so spelling `false` here would clear a ratchet
1262 // that is set — and a ratchet any ordinary frame can clear is not
1263 // one. In practice the elision cache means this rarely rewrites an
1264 // existing record, which is exactly the kind of "cannot happen"
1265 // that stops being true after a refactor.
1266 ctrl_freshness_proved: self.signs_freshness_bound_control(peer_id),
1267 last_reset_ms: self
1268 .control_reset_watermark
1269 .get(peer_id)
1270 .copied()
1271 .unwrap_or(0),
1272 };
1273 match serde_json::to_vec(&entry) {
1274 Ok(data) => {
1275 match storage.store(storage_keys::ENCRYPTION_CAPABLE_PEERS, peer_id, &data) {
1276 Ok(()) => true,
1277 Err(e) => {
1278 warn!(peer_id = %peer_id, error = %e, "Failed to persist encryption-capability record");
1279 false
1280 }
1281 }
1282 }
1283 Err(e) => {
1284 warn!(peer_id = %peer_id, error = %e, "Failed to serialize encryption-capability record");
1285 false
1286 }
1287 }
1288 }
1289
1290 /// Restores the durable encryption-capability records from storage.
1291 ///
1292 /// Skips corrupted entries with a warning (best-effort restore), and is
1293 /// bounded by [`MAX_RESTORE_KEYS_PER_CATEGORY`] like every other category
1294 /// walk on the boot path, so a store listing more entries than any
1295 /// legitimate run can produce cannot turn initialization into an unbounded
1296 /// number of loads.
1297 ///
1298 /// Unlike the two cache restores (`restore_peer_key_packages`,
1299 /// `restore_peer_capabilities`), the overflow is **not** pruned from
1300 /// durable storage, and there is no in-memory cap applied here. A cached
1301 /// key package only costs a re-exchange when it is dropped; one of these
1302 /// records is the sole durable evidence that a peer runs MLS, and dropping
1303 /// it silently re-opens the plaintext gate for that peer. Stranding an
1304 /// over-cap entry is the strictly safer failure, so this walk stops and
1305 /// says so rather than shrinking the store.
1306 ///
1307 /// `mark_encryption_capable` applies its own bound
1308 /// ([`MAX_ENCRYPTION_CAPABLE_PEERS`](super::MAX_ENCRYPTION_CAPABLE_PEERS)),
1309 /// by refusal rather than eviction, and restore runs before `start()`
1310 /// admits traffic — so peers with durable records are first in line for
1311 /// the capacity a forged-sender flood would otherwise consume.
1312 ///
1313 /// That ordering is only worth anything because the category itself is
1314 /// bounded by the same cap: [`Self::record_encryption_capable`] writes a
1315 /// record only for a peer the capped set accepted. Were it not, a flood of
1316 /// self-consistent identities could fill the category past
1317 /// [`MAX_RESTORE_KEYS_PER_CATEGORY`] and push a legitimate peer's record
1318 /// outside the window this walk reads — losing exactly the protection the
1319 /// category exists to carry.
1320 pub(super) fn restore_encryption_capable_peers(&mut self) {
1321 let Some(storage) = &self.secure_storage else {
1322 return;
1323 };
1324 let peer_ids = match storage.list_keys(storage_keys::ENCRYPTION_CAPABLE_PEERS) {
1325 Ok(keys) => keys,
1326 Err(e) => {
1327 warn!(error = %e, "Failed to list encryption-capability records, starting empty");
1328 return;
1329 }
1330 };
1331 let listed = peer_ids.len();
1332 // Collected before marking: `mark_encryption_capable` takes `&mut self`
1333 // while `storage` is borrowed from `self`.
1334 let mut capable: Vec<(String, bool, i64)> = Vec::new();
1335 for peer_id in peer_ids.iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
1336 // Storage keys bypass `UserId::new()`, so a corrupted or
1337 // pre-validation-era entry could contain hostile characters.
1338 if UserId::new(peer_id).is_err() {
1339 warn!(peer_id = %peer_id, "Skipping capability record with invalid peer ID");
1340 continue;
1341 }
1342 match storage.load(storage_keys::ENCRYPTION_CAPABLE_PEERS, peer_id) {
1343 Ok(Some(data)) => match serde_json::from_slice::<EncryptionCapableEntry>(&data) {
1344 // The record's presence *is* the encryption-capability
1345 // fact; its timestamp is diagnostic. The ratchet flag is
1346 // read, because that one is a field rather than a
1347 // presence. Deserializing anyway keeps a garbage record
1348 // from being read as evidence.
1349 Ok(entry) => capable.push((
1350 peer_id.clone(),
1351 entry.ctrl_freshness_proved,
1352 entry.last_reset_ms,
1353 )),
1354 Err(e) => {
1355 warn!(peer_id = %peer_id, error = %e, "Skipping corrupted capability record");
1356 }
1357 },
1358 Ok(None) => {}
1359 Err(e) => {
1360 warn!(peer_id = %peer_id, error = %e, "Failed to load capability record");
1361 }
1362 }
1363 }
1364 let restored = capable.len() as u32;
1365 for (peer_id, ctrl_freshness_proved, last_reset_ms) in capable {
1366 // Read back from the category, so the record demonstrably exists:
1367 // seeding the elision cache here is what keeps the first verified
1368 // frame from a restored peer from rewriting a record that is
1369 // already on disk.
1370 self.encryption_capable_persisted.insert(peer_id.clone());
1371 let marked = self.mark_encryption_capable(&peer_id);
1372 if marked {
1373 // Restored through the same subset rule the live path applies,
1374 // so a category that somehow holds more peers than the cap
1375 // cannot seed a set that the cap is supposed to bound.
1376 if ctrl_freshness_proved {
1377 self.control_freshness_peers.insert(peer_id.clone());
1378 }
1379 if last_reset_ms != 0 {
1380 // Losing this across a restart would make every reset this
1381 // node already acted on spendable again, which is the
1382 // replay it exists to deny.
1383 self.control_reset_watermark.insert(peer_id, last_reset_ms);
1384 }
1385 }
1386 }
1387 if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
1388 warn!(
1389 listed,
1390 cap = MAX_RESTORE_KEYS_PER_CATEGORY,
1391 "Capability store listed more peers than any legitimate run can produce; ignoring \
1392 the tail rather than pruning it, since a dropped record re-opens the plaintext gate"
1393 );
1394 }
1395 if restored > 0 {
1396 info!(
1397 count = restored,
1398 "Restored encryption-capability records from storage"
1399 );
1400 }
1401 }
1402}