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