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