Skip to main content

offline_protocol/protocol/
security.rs

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