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}