Skip to main content

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}