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, storage_keys, ControlGateOutcome, EncryptionCapableEntry,
6 InternalMessageResult, OfflineProtocol, CTRL_PK_META_KEY, CTRL_SIGN_DOMAIN, CTRL_SIG_META_KEY,
7 DATA_PLANE_PREFIXES, INTERNAL_PREFIXES, MAX_CONTROL_GATE_WARNED_PEERS,
8 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_transport::TransportType;
15use std::time::{Duration, Instant};
16use tracing::{debug, error, info, warn};
17
18/// Minimum gap between [`SecurityWarningCode::PushKeyPackagePoolExhausted`]
19/// emissions. Matches the suppression the Nostr slot-exhaustion and
20/// unauthorized-membership reports use, for the same reason: the condition
21/// persists, so an unsuppressed warning reports one cause many times.
22const PUSH_KEY_PACKAGE_WARNING_SUPPRESS_INTERVAL: Duration = Duration::from_secs(300);
23
24impl OfflineProtocol {
25 /// Builds a canonical signing payload using length-prefixed encoding.
26 ///
27 /// Each field is encoded as `<4-byte big-endian length><utf-8 bytes>`,
28 /// making the encoding unambiguous regardless of field content (no
29 /// delimiter-collision risk).
30 pub(crate) fn build_canonical_payload(message: &Message) -> Result<Vec<u8>> {
31 let fields: [&str; 4] = [
32 message.sender.as_str(),
33 &message.id.as_str(),
34 message.recipient.as_str(),
35 &message.content,
36 ];
37 let mut buf = Vec::with_capacity(
38 CTRL_SIGN_DOMAIN.len() + fields.iter().map(|f| 4 + f.len()).sum::<usize>(),
39 );
40 buf.extend_from_slice(CTRL_SIGN_DOMAIN);
41 for field in &fields {
42 let len: u32 = field.len().try_into().map_err(|_| {
43 Error::Other(format!(
44 "Field too large for canonical payload length prefix: {} bytes",
45 field.len()
46 ))
47 })?;
48 buf.extend_from_slice(&len.to_be_bytes());
49 buf.extend_from_slice(field.as_bytes());
50 }
51 Ok(buf)
52 }
53
54 /// Signs a control message by adding an Ed25519 signature and the sender's
55 /// public key to the message metadata.
56 ///
57 /// Returns `Ok(())` on success (or when MLS is not initialized — unsigned
58 /// messages are acceptable for backward compatibility with pre-MLS peers).
59 /// Returns `Err` when MLS is initialized but signing fails, so the caller
60 /// can decide whether to abort the send.
61 pub(super) fn sign_control_message(&self, message: &mut Message) -> Result<()> {
62 let mls = match self.mls_manager.as_ref() {
63 Some(m) => m,
64 None => {
65 debug!("MLS not initialized — sending unsigned control message");
66 return Ok(());
67 }
68 };
69 let manager = match mls.read() {
70 Ok(guard) => guard,
71 Err(e) => {
72 let reason = format!("MLS lock poisoned — cannot sign control message: {}", e);
73 error!(error = %e, "MLS lock poisoned — cannot sign control message");
74 return Err(Error::Other(reason));
75 }
76 };
77
78 Self::sign_control_message_with(message, &manager)
79 }
80
81 /// Stamps `message` with an Ed25519 signature and public key from
82 /// `manager`'s identity.
83 ///
84 /// Split out from [`Self::sign_control_message`] so a caller that already
85 /// holds the signing identity — a test standing in for a peer's device —
86 /// can produce the same bytes the peer's own instance would, rather than
87 /// reimplementing the canonical payload and getting it subtly wrong.
88 pub(crate) fn sign_control_message_with(
89 message: &mut Message,
90 manager: &offline_protocol_mls::MlsManager,
91 ) -> Result<()> {
92 let public_key = match manager.get_identity_public_key() {
93 Ok(pk) => pk,
94 Err(e) => {
95 let reason = format!("Failed to get identity public key: {}", e);
96 error!(error = %e, "Failed to get identity public key — cannot sign control message");
97 return Err(Error::Other(reason));
98 }
99 };
100 let canonical = Self::build_canonical_payload(message)?;
101 let signature = match manager.sign_data(&canonical) {
102 Ok(sig) => sig,
103 Err(e) => {
104 let reason = format!("Failed to sign control message: {}", e);
105 error!(error = %e, "Failed to sign control message");
106 return Err(Error::Other(reason));
107 }
108 };
109
110 message
111 .metadata
112 .insert(CTRL_SIG_META_KEY.to_string(), base64_encode(&signature));
113 message
114 .metadata
115 .insert(CTRL_PK_META_KEY.to_string(), base64_encode(&public_key));
116 Ok(())
117 }
118
119 /// Verifies a control message's Ed25519 signature from metadata, and that
120 /// the key which signed it is the one the claimed sender's address names.
121 ///
122 /// Returns:
123 /// - `Ok(true)` — valid signature, and the signing key derives to `sender`
124 /// - `Ok(false)` — no signature metadata at all (unsigned message)
125 /// - `Err(..)` — signature invalid, sender/key derivation mismatch, or
126 /// malformed metadata (e.g. public key present without a signature,
127 /// or vice versa)
128 pub(super) fn verify_control_message(&mut self, message: &Message) -> Result<bool> {
129 let sig_b64 = match message.metadata.get(CTRL_SIG_META_KEY) {
130 Some(s) => s,
131 None => {
132 // If the public key metadata is present without a signature,
133 // treat this as a malformed message rather than unsigned.
134 if message.metadata.contains_key(CTRL_PK_META_KEY) {
135 return Err(Error::Other(
136 "Control message has public key but missing signature (malformed)"
137 .to_string(),
138 ));
139 }
140 return Ok(false); // Truly unsigned — caller decides policy
141 }
142 };
143 let pk_b64 = match message.metadata.get(CTRL_PK_META_KEY) {
144 Some(s) => s,
145 None => {
146 return Err(Error::Other(
147 "Control message has signature but missing public key".to_string(),
148 ));
149 }
150 };
151
152 let signature = base64_decode(sig_b64)
153 .map_err(|e| Error::Other(format!("Invalid control signature encoding: {}", e)))?;
154 let public_key = base64_decode(pk_b64)
155 .map_err(|e| Error::Other(format!("Invalid control public key encoding: {}", e)))?;
156
157 // Verify Ed25519 signature over a length-prefixed canonical payload
158 // that binds sender, message ID, recipient, and content.
159 let canonical = Self::build_canonical_payload(message)?;
160 let valid =
161 offline_protocol_mls::MlsManager::verify_signature(&public_key, &canonical, &signature)
162 .map_err(|e| Error::Other(format!("Signature verification error: {}", e)))?;
163
164 if !valid {
165 return Err(Error::Other(
166 "Control message signature verification failed".to_string(),
167 ));
168 }
169
170 // The claim is now *proved*, not pinned: the address the frame claims
171 // to come from is the hash of the key that just signed it, or it is
172 // not this peer's frame.
173 self.verify_sender_derivation(message.sender.as_str(), &public_key)?;
174
175 // Reaching here means an Ed25519 signature over the canonical payload
176 // verified against a key that derives to the claimed sender, and that
177 // key *is* the peer's MLS identity key (`get_identity_public_key`
178 // returns the credential's `signature_key`). So this is proof the peer
179 // runs MLS.
180 self.record_encryption_capable(message.sender.as_str());
181 Ok(true)
182 }
183
184 /// Requires `public_key` to derive to the address in `sender`.
185 ///
186 /// This one function is what the TOFU pin store used to be. The store held
187 /// a key per peer so a later frame could be compared against the first one
188 /// seen; an address makes that comparison unnecessary, because the address
189 /// already *is* `bech32m(0x01 ‖ SHA-256(key)[..20])`. Impersonation stops
190 /// costing "be the first to claim the name" and starts costing a 160-bit
191 /// second preimage (~2^160). Note that this is the *targeted* figure — the
192 /// birthday bound on the same truncation is ~2^80, which buys two keys
193 /// under one address rather than a specific peer's address; see
194 /// `Address::HASH_LEN` for why that trade was taken.
195 ///
196 /// # Why an unparseable sender is rejected
197 ///
198 /// A sender that is not an address has no derivation to check, and the
199 /// tempting answer — pass, there is nothing to compare — is the whole
200 /// bypass: an attacker would claim a nickname and skip the gate. So a
201 /// control frame whose sender is not an address is refused outright. This
202 /// is also what makes the check unconditional in the sense that matters:
203 /// there is no input for which it declines to run.
204 fn verify_sender_derivation(&mut self, sender: &str, public_key: &[u8]) -> Result<()> {
205 let claimed = match sender.parse::<Address>() {
206 Ok(claimed) => claimed,
207 Err(e) => {
208 // Reported like the mismatch arm below, and for the same
209 // reason: this is the other half of "the claim cannot be bound
210 // to the key that signed it". The event stays identifier-free
211 // — `sender` here is an unparseable, attacker-chosen string, so
212 // rendering it would put arbitrary wire text on the sink. The
213 // rendered error below reaches the device log through the
214 // caller's `warn!`.
215 self.warn_control_gate_rejection(
216 sender,
217 SecurityWarningCode::SenderAddressMismatch,
218 "Control message sender is not an address, so no signing key can be bound \
219 to the claim",
220 );
221 return Err(Error::Other(format!(
222 "Control message sender '{}' is not an address: {}",
223 sender, e
224 )));
225 }
226 };
227
228 let derived = offline_protocol_mls::MlsManager::derive_address(public_key)
229 .map_err(|e| Error::Other(format!("Cannot derive an address from the key: {}", e)))?;
230
231 if derived != claimed {
232 warn!(
233 sender = %sender,
234 derived = %derived,
235 "Control message signing key does not derive to the claimed sender"
236 );
237 self.warn_control_gate_rejection(
238 sender,
239 SecurityWarningCode::SenderAddressMismatch,
240 "Signing key does not derive to the claimed sender address (impersonation attempt)",
241 );
242 return Err(Error::Other(format!(
243 "Sender address mismatch: '{}' claimed, key derives to '{}'",
244 sender, derived
245 )));
246 }
247 Ok(())
248 }
249
250 /// Marks `peer_id` encryption-capable, in memory and durably.
251 ///
252 /// The durable half is not bookkeeping: `encryption_capable_peers` is what
253 /// keeps the plaintext-downgrade gate shut, and a session can be torn down
254 /// remotely, so without a record that outlives the session the next launch
255 /// would come up knowing nothing about a peer it had verified and re-open
256 /// the gate for them. The TOFU store used to be that record as a side
257 /// effect of holding pins; it is now the only thing this category does.
258 ///
259 /// # Why the write is gated on the in-memory mark
260 ///
261 /// `mark_encryption_capable` refuses past
262 /// [`MAX_ENCRYPTION_CAPABLE_PEERS`](super::MAX_ENCRYPTION_CAPABLE_PEERS),
263 /// and persisting regardless would leave the durable category unbounded
264 /// while the set it feeds is capped. That gap is reachable: minting an
265 /// identity that signs honestly as itself costs one Ed25519 keygen, so an
266 /// attacker can write records without limit. The damage is not the disk —
267 /// it is that `restore_encryption_capable_peers` reads only the first
268 /// [`MAX_RESTORE_KEYS_PER_CATEGORY`] keys the store lists, so a flooded
269 /// category can push a legitimate peer's record out of the restore window
270 /// and re-open the plaintext gate for them on the next launch. Writing only
271 /// what the capped set accepted holds the category under that cap, which is
272 /// what makes the restore walk's "durable records are first in line" claim
273 /// true rather than aspirational. The deleted TOFU store had this property
274 /// too — its store-full branch declined to pin *and* to persist.
275 ///
276 /// # Why the write is skipped once the record is known durable
277 ///
278 /// The record's presence *is* the fact; its `last_seen_ms` is diagnostic and
279 /// nothing reads it back as a policy input (see [`EncryptionCapableEntry`]).
280 /// Rewriting it on every verified frame would mean a synchronous credential-
281 /// store write per control message — on iOS and Android a Keychain/Keystore
282 /// round-trip — to refresh a field no decision depends on, and the gated
283 /// prefixes include the chatty ones (`__TYPING__`, `__READ_RECEIPT__`,
284 /// `__PRESENCE__`). The pin store this replaced did rewrite per frame, but
285 /// it had to: the timestamp drove its LRU eviction. Nothing evicts here.
286 ///
287 /// The skip keys on [`Self::encryption_capable_persisted`] — writes that
288 /// actually landed — rather than on set membership, because
289 /// [`Self::persist_encryption_capable`] is best-effort. Keying on the
290 /// in-memory set would let one transient storage failure strand the peer
291 /// unwritten for the rest of the process, which is precisely the state that
292 /// re-opens the plaintext gate for them on the next launch. Keyed this way
293 /// a failed write is simply retried on their next verified frame.
294 ///
295 /// # The mark is never skipped, only the write
296 ///
297 /// Note the ordering: `mark_encryption_capable` runs *before* the cache is
298 /// consulted. Returning early on a cache hit would skip the mark too, and
299 /// the two can legitimately disagree — a failed `initialize_mls` rolls
300 /// `encryption_capable_peers` back to its pre-restore snapshot while the
301 /// cache still remembers the records restore read off disk. A peer in that
302 /// window would then be waved through here without ever entering the set
303 /// that holds the plaintext gate shut for them: the exact fail-open this
304 /// field exists to prevent, bought for a saved write.
305 pub(super) fn record_encryption_capable(&mut self, peer_id: &str) {
306 if !self.mark_encryption_capable(peer_id) {
307 return;
308 }
309 if self.encryption_capable_persisted.contains(peer_id) {
310 return;
311 }
312 if self.persist_encryption_capable(peer_id) {
313 self.encryption_capable_persisted
314 .insert(peer_id.to_string());
315 }
316 }
317
318 /// Security gate for control messages. Validates transport-level sender
319 /// identity and cryptographic signature before allowing the message to
320 /// proceed through `process_internal_message`.
321 ///
322 /// Returns [`ControlGateOutcome::Rejected`] to drop the message (without
323 /// sending a delivery ACK), or [`ControlGateOutcome::Proceed`] to allow it
324 /// through — carrying whether the frame was actually signed, so a handler
325 /// that treats a payload field as authenticated can check rather than
326 /// assume. See [`ControlGateOutcome::Proceed`] for what that bit does and
327 /// does not mean.
328 pub(super) fn security_gate_control_message(
329 &mut self,
330 message: &Message,
331 arrival_transport: Option<TransportType>,
332 ) -> ControlGateOutcome {
333 let content = &message.content;
334 let sender = message.sender.as_str();
335
336 if !Self::is_security_gated_prefix(content) {
337 // Not a security-gated control message — no gate needed, and
338 // nothing verified, so `signed` stays false.
339 return ControlGateOutcome::Proceed { signed: false };
340 }
341
342 // Transport-level identity check
343 if !self.validate_transport_sender(message) {
344 warn!(
345 sender = %sender,
346 message_id = %message.id,
347 "Dropping control message: sender/transport identity mismatch or missing"
348 );
349 self.warn_control_gate_rejection(
350 sender,
351 SecurityWarningCode::TransportIdentityMismatch,
352 "Control message sender does not match transport peer identity",
353 );
354 return ControlGateOutcome::Rejected(InternalMessageResult::SecurityRejected);
355 }
356
357 // Log telemetry when transport identity is absent (passed best-effort).
358 // This is not emitted as a SecurityWarning event because relayed/forwarded
359 // messages routinely lack transport_peer_id and flooding the event stream
360 // would desensitize operators to real warnings.
361 if message.transport_peer_id().is_none() {
362 debug!(
363 sender = %sender,
364 message_id = %message.id,
365 require_identity = %self.config.security.require_transport_identity,
366 "Control message passed without transport peer identity (best-effort)"
367 );
368 }
369
370 // Cryptographic signature check
371 let signed = match self.verify_control_message(message) {
372 Ok(true) => {
373 // Signed and verified — proceed
374 true
375 }
376 Ok(false) if Self::is_unsignable_relay_answer(message, arrival_transport) => {
377 // A relay-originated answer, which no peer signed because no
378 // peer sent it. See `RELAY_ANSWER_PREFIXES` for why this cannot
379 // be signature-gated and what does and does not protect it.
380 debug!(
381 sender = %sender,
382 message_id = %message.id,
383 "Accepting unsigned relay-originated control frame (no peer signs these)"
384 );
385 false
386 }
387 Ok(false) => {
388 // Unsigned control traffic is refused, unconditionally.
389 //
390 // This used to be a two-part policy: reject if the sender had a
391 // pin (a known-signed peer going quiet is a downgrade), else
392 // reject only under `require_transport_identity`. Both halves
393 // existed because an unsigned frame from a peer we knew nothing
394 // about was, at the time, indistinguishable from a legacy peer
395 // — there was no way to check an identity claim without prior
396 // contact, so refusing would have meant refusing first contact.
397 //
398 // Derived addresses remove that excuse: a signature is now
399 // *self-verifying* against the sender's own id, so a peer that
400 // will not sign is a peer making an unprovable claim, whether
401 // or not we have met them. Leaving any accepting path here
402 // would also leave the forged-`hop_count` bypass open — a
403 // spoofer who sets `hop_count > 0` skips the transport-identity
404 // strict match and lands exactly on this branch, and would then
405 // impersonate any peer without committing a signing key at all.
406 warn!(
407 sender = %sender,
408 message_id = %message.id,
409 "Dropping unsigned control message: control traffic must be signed"
410 );
411 self.warn_control_gate_rejection(
412 sender,
413 SecurityWarningCode::UnsignedControlRejected,
414 "Unsigned control message rejected: control traffic must carry a signature \
415 from the key its sender address derives from",
416 );
417 return ControlGateOutcome::Rejected(InternalMessageResult::SecurityRejected);
418 }
419 Err(err) => {
420 // Signature invalid, derivation mismatch, or malformed metadata — drop
421 warn!(
422 sender = %sender,
423 message_id = %message.id,
424 error = %err,
425 "Dropping control message: signature verification failed"
426 );
427 // Classified, not rendered. `err` names both the claimed sender
428 // and — on the derivation-mismatch arm — the address the
429 // signing key really derives to, and a `SecurityWarning`'s
430 // `reason` is shipped verbatim by the telemetry scrubber (only
431 // `peer_id`, the *claimed* sender, is hashed). Rendering it
432 // here would hand the sink the pair that de-anonymizes that
433 // hash, beside the deliberately identifier-free event
434 // `verify_sender_derivation` already emitted for the same
435 // refusal. The full error is in the `warn!` above.
436 self.warn_control_gate_rejection(
437 sender,
438 SecurityWarningCode::ControlSignatureInvalid,
439 "Control message rejected: its signature, signing metadata, or \
440 sender/key derivation failed verification",
441 );
442 return ControlGateOutcome::Rejected(InternalMessageResult::SecurityRejected);
443 }
444 };
445
446 ControlGateOutcome::Proceed { signed }
447 }
448
449 /// Whether this frame is a relay answer that structurally cannot be signed.
450 ///
451 /// All three conditions are required, and the two beyond the prefix are what
452 /// keep this from being a hole in the peer-to-peer path:
453 ///
454 /// - the prefix is one the relay server originates
455 /// ([`RELAY_ANSWER_PREFIXES`]);
456 /// - it arrived on the Internet transport, where the relay ingest lives;
457 /// - it carries no transport peer identity, which is what a locally
458 /// synthesized answer looks like — a real peer's frame on this transport
459 /// is attributed by the relay and would fail this.
460 ///
461 /// A peer sending one of these prefixes over the mesh, or over the relay
462 /// with an attributed identity, is therefore still required to sign it.
463 fn is_unsignable_relay_answer(
464 message: &Message,
465 arrival_transport: Option<TransportType>,
466 ) -> bool {
467 arrival_transport == Some(TransportType::Internet)
468 && message.transport_peer_id().is_none()
469 && RELAY_ANSWER_PREFIXES
470 .iter()
471 .any(|p| message.content.starts_with(p))
472 }
473
474 /// Validates that the claimed `message.sender` matches the transport-level
475 /// peer identity, if available. Returns `true` if validated or if no
476 /// transport identity is available (best-effort). Returns `false` if
477 /// there is a mismatch on a frame claiming direct origin.
478 ///
479 /// # Hop-count rule
480 ///
481 /// The strict match applies only to frames with `hop_count == 0` — the
482 /// frame's claim that the carrying peer *is* its origin. A frame with
483 /// `hop_count > 0` was mesh-relayed: the transport identity names the
484 /// nearest carrier (the relaying peer), not the origin in
485 /// `message.sender`, so a mismatch is expected and carries no spoofing
486 /// signal. Those frames fall back to the signature + derivation gate.
487 ///
488 /// A spoofer can forge `hop_count > 0` to skip this check, and that is now
489 /// uninteresting: the forged-hop path lands on the same signature +
490 /// derivation gate every other control frame passes, which refuses an
491 /// unsigned frame outright and refuses a signed one whose key does not
492 /// derive to the claimed sender. There is no configuration in which
493 /// skipping this check buys an attacker anything, because this check is no
494 /// longer what establishes identity — it only cross-checks an identity the
495 /// signature has already proved.
496 ///
497 /// That is also why `require_transport_identity` keeps its `false` default
498 /// rather than flipping with the rest of this work. Its remaining job is
499 /// the `None` branch below, and demanding transport identity there costs
500 /// real delivery for no authenticity: Nostr frames carry none by design
501 /// (the Nostr pubkey is not the protocol id — see
502 /// `nostr_message_received_inner`), and neither do relay frames that arrive
503 /// without a named sender. Turning it on rejects every control message on
504 /// those paths. Deleting the field belongs with the wider API sweep; until
505 /// then it is a hardening knob for deployments that run neither transport.
506 ///
507 /// # Relay / mesh forwarding
508 ///
509 /// Forwarded messages re-created via [`send_internal_message`] →
510 /// [`create_message`] have `transport_peer_id: None`, and because
511 /// `transport_peer_id` is `#[serde(skip)]` it is also `None` after
512 /// deserialization unless the receiving transport attaches an
513 /// authenticated identity (Internet relay, Reticulum). Frames relayed
514 /// verbatim by `try_relay_message` keep their original `sender` but
515 /// always arrive with `hop_count >= 1` (the relayer increments before
516 /// re-sending), so the hop-count rule above prevents this check from
517 /// rejecting them.
518 pub(super) fn validate_transport_sender(&self, message: &Message) -> bool {
519 match message.transport_peer_id() {
520 Some(transport_peer) => {
521 if message.hop_count.value() > 0 {
522 debug!(
523 claimed_sender = %message.sender,
524 transport_peer = %transport_peer,
525 hop_count = message.hop_count.value(),
526 message_id = %message.id,
527 "Relayed control message: transport peer is the carrier, not the origin; skipping strict match"
528 );
529 } else if message.sender.as_str() != transport_peer {
530 warn!(
531 claimed_sender = %message.sender,
532 transport_peer = %transport_peer,
533 message_id = %message.id,
534 "Sender identity mismatch: claimed sender does not match transport peer"
535 );
536 return false;
537 }
538 }
539 None => {
540 if self.config.security.require_transport_identity {
541 warn!(
542 claimed_sender = %message.sender,
543 message_id = %message.id,
544 "Rejecting control message without transport peer identity \
545 (require_transport_identity=true)"
546 );
547 return false;
548 }
549 }
550 }
551 true
552 }
553
554 /// Emits a `SecurityWarning` event for the given peer.
555 pub(super) fn emit_security_warning(
556 &self,
557 peer_id: &str,
558 reason_code: SecurityWarningCode,
559 reason: impl Into<String>,
560 ) {
561 self.emit_event(Event::security_warning(
562 peer_id.to_string(),
563 reason_code,
564 reason.into(),
565 ));
566 }
567
568 /// The bit [`Self::warn_control_gate_rejection`] tracks for `code`.
569 ///
570 /// Zero for anything the control gate does not emit, which reads as "no bit
571 /// to suppress on" and lets such a code through unthrottled — the throttle
572 /// is deliberately scoped to the gate's own rejections rather than to
573 /// security warnings in general.
574 fn control_gate_warning_bit(code: SecurityWarningCode) -> u8 {
575 match code {
576 SecurityWarningCode::TransportIdentityMismatch => 1 << 0,
577 SecurityWarningCode::ControlSignatureInvalid => 1 << 1,
578 SecurityWarningCode::UnsignedControlRejected => 1 << 2,
579 SecurityWarningCode::SenderAddressMismatch => 1 << 3,
580 _ => 0,
581 }
582 }
583
584 /// Emits a control-gate rejection warning for `peer_id`, at most once per
585 /// peer per reason code (tracked in a bounded map that resets at
586 /// [`MAX_CONTROL_GATE_WARNED_PEERS`], so a peer may re-warn after a
587 /// forged-sender flood).
588 ///
589 /// Every one of these codes is reachable from an unauthenticated frame
590 /// naming an attacker-chosen sender — a gate rejection is by definition a
591 /// frame that proved nothing — so emitting per frame hands an off-path
592 /// injector a way to bury the signal that matters
593 /// ([`SecurityWarningCode::SenderAddressMismatch`], which per its own docs
594 /// has no benign reading) under noise. The per-rejection `warn!` logs stay
595 /// at the call sites; this throttles only the app-facing event.
596 ///
597 /// Suppression is per code, not per peer, so a peer that first trips one
598 /// rejection still surfaces a later, different one. A single frame can
599 /// legitimately report two codes — a derivation mismatch reports the
600 /// specific `SenderAddressMismatch` and then the gate's general
601 /// `ControlSignatureInvalid` — and both are bounded to one event each.
602 pub(super) fn warn_control_gate_rejection(
603 &mut self,
604 peer_id: &str,
605 reason_code: SecurityWarningCode,
606 reason: impl Into<String>,
607 ) {
608 let bit = Self::control_gate_warning_bit(reason_code);
609 if bit != 0 {
610 if self
611 .control_gate_warned
612 .get(peer_id)
613 .is_some_and(|mask| mask & bit != 0)
614 {
615 return;
616 }
617 // The keys are attacker-controlled, so the map resets at capacity
618 // rather than growing without bound — same trade as
619 // `warn_plaintext_receive_rejected`.
620 if self.control_gate_warned.len() >= MAX_CONTROL_GATE_WARNED_PEERS
621 && !self.control_gate_warned.contains_key(peer_id)
622 {
623 self.control_gate_warned.clear();
624 }
625 *self
626 .control_gate_warned
627 .entry(peer_id.to_string())
628 .or_default() |= bit;
629 }
630 self.emit_security_warning(peer_id, reason_code, reason);
631 }
632
633 /// Emits a [`SecurityWarningCode::PlaintextSend`] warning for `peer_id`,
634 /// at most once per peer per protocol instance. Called from the outbound
635 /// plaintext fall-throughs, which are reachable only under the explicit
636 /// `require_encryption = false` opt-out — the warning keeps cleartext
637 /// flows visible without emitting per message.
638 pub(super) fn warn_plaintext_send(&mut self, peer_id: &str) {
639 if self.plaintext_send_warned.insert(peer_id.to_string()) {
640 warn!(
641 recipient = %peer_id,
642 "Outbound message sent as plaintext (encryption disabled or MLS \
643 uninitialized, require_encryption=false)"
644 );
645 self.emit_security_warning(
646 peer_id,
647 SecurityWarningCode::PlaintextSend,
648 "Outbound message sent as plaintext: encryption is disabled or MLS \
649 is not initialized, and require_encryption is false",
650 );
651 }
652 }
653
654 /// Emits a [`SecurityWarningCode::PushKeyPackagePoolExhausted`] warning,
655 /// at most once per [`PUSH_KEY_PACKAGE_WARNING_SUPPRESS_INTERVAL`].
656 ///
657 /// The condition — the per-peer key-package pool at its ceiling, so this
658 /// advertisement reuses a package another peer already holds — persists
659 /// until packages are consumed or expire, and every push meanwhile would
660 /// otherwise emit. Suppressed on time rather than per peer because the
661 /// interesting fact is the pool's state, not which peer happened to ask.
662 pub(super) fn warn_push_key_package_pool_exhausted(&mut self, peer_id: &str) {
663 let now = Instant::now();
664 if let Some(last) = self.last_push_key_package_warning {
665 if now.duration_since(last) < PUSH_KEY_PACKAGE_WARNING_SUPPRESS_INTERVAL {
666 return;
667 }
668 }
669 self.last_push_key_package_warning = Some(now);
670 warn!(
671 peer_id = %peer_id,
672 "Key-package pool at capacity; this peer shares an init key with another"
673 );
674 self.emit_security_warning(
675 peer_id,
676 SecurityWarningCode::PushKeyPackagePoolExhausted,
677 "key-package pool at capacity: this peer was advertised an init key \
678 another peer also holds, weakening forward secrecy at session \
679 establishment until packages are consumed or expire",
680 );
681 }
682
683 /// Emits a [`SecurityWarningCode::PlaintextReceiveRejected`] warning for
684 /// `peer_id`, at most once per peer (tracked in a bounded set that resets
685 /// at [`MAX_PLAINTEXT_RECEIVE_WARNED_PEERS`], so a peer may re-warn after
686 /// a forged-sender flood). Called from the inbound plaintext policy gate
687 /// ([`Self::accept_plaintext_content`]) when cleartext text or legacy
688 /// media is dropped — the per-rejection `warn!` logs live at the call
689 /// sites; this keeps the event stream from flooding when a legacy or
690 /// malicious peer keeps sending.
691 pub(super) fn warn_plaintext_receive_rejected(&mut self, peer_id: &str, detail: &str) {
692 if self.plaintext_receive_warned.contains(peer_id) {
693 return;
694 }
695 // The keys are attacker-controlled (wire-claimed sender ids), so the
696 // set resets at capacity instead of growing without bound: a forged-
697 // sender flood degrades the throttle to once-per-peer-per-generation
698 // while memory stays capped.
699 if self.plaintext_receive_warned.len() >= MAX_PLAINTEXT_RECEIVE_WARNED_PEERS {
700 self.plaintext_receive_warned.clear();
701 }
702 self.plaintext_receive_warned.insert(peer_id.to_string());
703 self.emit_security_warning(
704 peer_id,
705 SecurityWarningCode::PlaintextReceiveRejected,
706 detail,
707 );
708 }
709
710 /// Verifies the relay's `AddressDeclared` acknowledgement against the
711 /// address this node actually holds.
712 ///
713 /// This is the lockstep assertion for the relay leg of addressing. The
714 /// bridge declares [`Self::local_address`] and signs a proof over a
715 /// per-connection challenge; the relay verifies that the declared address
716 /// derives from the signing key and echoes back what it bound. Agreement
717 /// means every frame the relay forwards from here on is attributed to the
718 /// same identity the core stamps into `Message.sender`, which is what
719 /// `validate_transport_sender` strict-matches on the receiving side.
720 ///
721 /// A disagreement is reported, never acted on: see
722 /// [`SecurityWarningCode::RelayAddressBindingMismatch`] for why tearing the
723 /// connection down would buy nothing. Callers pass the echoed address
724 /// verbatim — an empty or malformed one simply cannot match and is reported
725 /// like any other mismatch.
726 ///
727 /// Reached only through the FFI's dedicated entry point, not through
728 /// message-plane injection, so a notification payload cannot synthesize it.
729 pub fn on_relay_address_declared(&self, declared: &str) {
730 match self.local_address() {
731 Some(local) if local == declared => {
732 info!(
733 address = %declared,
734 "Relay bound this connection to our address; frames are attributed by address"
735 );
736 }
737 Some(local) => {
738 warn!(
739 declared = %declared,
740 local = %local,
741 "Relay acknowledged an address that is not ours"
742 );
743 // `declared` is this event's `peer_id`, where the scrubber
744 // hashes it; interpolating it into `reason` — which is shipped
745 // verbatim — would undo that hashing inside the same record.
746 // The raw pair is in the `warn!` above.
747 self.emit_security_warning(
748 declared,
749 SecurityWarningCode::RelayAddressBindingMismatch,
750 "relay bound this connection to an address that is not this device's: \
751 its frames are attributed to an identity we cannot prove, so receivers \
752 will reject our security-gated control traffic",
753 );
754 }
755 None => {
756 // The bridge does not declare before an identity exists, so an
757 // acknowledgement here answers a declaration this node never
758 // made. Same disposition, distinct text: nothing local can be
759 // compared, which is itself the finding.
760 warn!(
761 declared = %declared,
762 "Relay acknowledged an address declaration this node never made"
763 );
764 self.emit_security_warning(
765 declared,
766 SecurityWarningCode::RelayAddressBindingMismatch,
767 "relay bound this connection to an address while this device has no \
768 established identity to declare: no declaration was sent from here",
769 );
770 }
771 }
772 }
773
774 /// Records the relay refusing this connection's address declaration.
775 ///
776 /// Non-fatal by contract on both sides — the connection stays authenticated
777 /// and keeps working in account-name space. What degrades is *new* MLS
778 /// session establishment over the relay; see
779 /// [`SecurityWarningCode::RelayAddressDeclarationRefused`]. `reason` is the
780 /// relay's own text: opaque, remote-chosen, and logged rather than
781 /// emitted — an event field never carries text a remote party wrote.
782 ///
783 /// Attributed to this node rather than to a peer (the failure is ours, and
784 /// no peer is involved), following the same convention as the Nostr
785 /// slot-exhaustion warning.
786 pub fn on_relay_address_declaration_refused(&self, reason: &str) {
787 warn!(
788 reason = %reason,
789 "Relay refused our address declaration; staying in account-name space"
790 );
791 // The relay's own wording does not travel onto the event: it is
792 // remote-chosen text of arbitrary length and content, and this event's
793 // `reason` is shipped verbatim to telemetry sinks. The code *is* the
794 // classification; the wording stays in the `warn!` above.
795 self.emit_security_warning(
796 &self.local_id,
797 SecurityWarningCode::RelayAddressDeclarationRefused,
798 "relay refused this connection's address declaration: frames stay \
799 attributed by account name, so new encrypted sessions cannot be \
800 established over the relay until a later connection declares successfully",
801 );
802 }
803
804 /// Returns `true` if the message content starts with any internal prefix.
805 /// Used for injection prevention on the public send APIs.
806 pub(crate) fn is_internal_prefix(content: &str) -> bool {
807 INTERNAL_PREFIXES.iter().any(|p| content.starts_with(p))
808 }
809
810 /// Returns `true` if the message content starts with a control-plane
811 /// prefix that requires security gate enforcement (transport identity +
812 /// signature verification + sender derivation). Data-plane prefixes listed in
813 /// `DATA_PLANE_PREFIXES` (e.g. `__MLS_ENC__`) are excluded because MLS
814 /// provides its own authentication layer.
815 pub(crate) fn is_security_gated_prefix(content: &str) -> bool {
816 // A prefix is security-gated if it is an internal prefix AND not a
817 // data-plane prefix. This derives the gated set from INTERNAL_PREFIXES
818 // minus DATA_PLANE_PREFIXES, so any new internal prefix is automatically
819 // gated unless explicitly excluded.
820 Self::is_internal_prefix(content)
821 && !DATA_PLANE_PREFIXES.iter().any(|p| content.starts_with(p))
822 }
823
824 /// Persists the durable "this peer runs MLS" record.
825 ///
826 /// Best-effort and idempotent: the in-memory set is already updated by the
827 /// time this runs, so a storage failure costs the knowledge only across a
828 /// restart — and costs it in the safe direction, since the peer re-proves
829 /// capability on their next signed control message.
830 ///
831 /// Returns whether the record is now durable, which is what lets
832 /// [`Self::record_encryption_capable`] skip the repeat write without
833 /// stranding a peer whose write failed. A node with no secure storage
834 /// answers `false` forever — correctly: nothing was persisted, and there is
835 /// no cost to re-answering that on each frame.
836 pub(super) fn persist_encryption_capable(&self, peer_id: &str) -> bool {
837 let Some(storage) = &self.secure_storage else {
838 return false;
839 };
840 let entry = EncryptionCapableEntry {
841 last_seen_ms: Utc::now().timestamp_millis(),
842 };
843 match serde_json::to_vec(&entry) {
844 Ok(data) => {
845 match storage.store(storage_keys::ENCRYPTION_CAPABLE_PEERS, peer_id, &data) {
846 Ok(()) => true,
847 Err(e) => {
848 warn!(peer_id = %peer_id, error = %e, "Failed to persist encryption-capability record");
849 false
850 }
851 }
852 }
853 Err(e) => {
854 warn!(peer_id = %peer_id, error = %e, "Failed to serialize encryption-capability record");
855 false
856 }
857 }
858 }
859
860 /// Restores the durable encryption-capability records from storage.
861 ///
862 /// Skips corrupted entries with a warning (best-effort restore), and is
863 /// bounded by [`MAX_RESTORE_KEYS_PER_CATEGORY`] like every other category
864 /// walk on the boot path, so a store listing more entries than any
865 /// legitimate run can produce cannot turn initialization into an unbounded
866 /// number of loads.
867 ///
868 /// Unlike the two cache restores (`restore_peer_key_packages`,
869 /// `restore_peer_capabilities`), the overflow is **not** pruned from
870 /// durable storage, and there is no in-memory cap applied here. A cached
871 /// key package only costs a re-exchange when it is dropped; one of these
872 /// records is the sole durable evidence that a peer runs MLS, and dropping
873 /// it silently re-opens the plaintext gate for that peer. Stranding an
874 /// over-cap entry is the strictly safer failure, so this walk stops and
875 /// says so rather than shrinking the store.
876 ///
877 /// `mark_encryption_capable` applies its own bound
878 /// ([`MAX_ENCRYPTION_CAPABLE_PEERS`](super::MAX_ENCRYPTION_CAPABLE_PEERS)),
879 /// by refusal rather than eviction, and restore runs before `start()`
880 /// admits traffic — so peers with durable records are first in line for
881 /// the capacity a forged-sender flood would otherwise consume.
882 ///
883 /// That ordering is only worth anything because the category itself is
884 /// bounded by the same cap: [`Self::record_encryption_capable`] writes a
885 /// record only for a peer the capped set accepted. Were it not, a flood of
886 /// self-consistent identities could fill the category past
887 /// [`MAX_RESTORE_KEYS_PER_CATEGORY`] and push a legitimate peer's record
888 /// outside the window this walk reads — losing exactly the protection the
889 /// category exists to carry.
890 pub(super) fn restore_encryption_capable_peers(&mut self) {
891 let Some(storage) = &self.secure_storage else {
892 return;
893 };
894 let peer_ids = match storage.list_keys(storage_keys::ENCRYPTION_CAPABLE_PEERS) {
895 Ok(keys) => keys,
896 Err(e) => {
897 warn!(error = %e, "Failed to list encryption-capability records, starting empty");
898 return;
899 }
900 };
901 let listed = peer_ids.len();
902 // Collected before marking: `mark_encryption_capable` takes `&mut self`
903 // while `storage` is borrowed from `self`.
904 let mut capable: Vec<String> = Vec::new();
905 for peer_id in peer_ids.iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
906 // Storage keys bypass `UserId::new()`, so a corrupted or
907 // pre-validation-era entry could contain hostile characters.
908 if UserId::new(peer_id).is_err() {
909 warn!(peer_id = %peer_id, "Skipping capability record with invalid peer ID");
910 continue;
911 }
912 match storage.load(storage_keys::ENCRYPTION_CAPABLE_PEERS, peer_id) {
913 Ok(Some(data)) => match serde_json::from_slice::<EncryptionCapableEntry>(&data) {
914 // The record's presence *is* the fact; its timestamp is
915 // diagnostic. Deserializing anyway keeps a garbage record
916 // from being read as evidence.
917 Ok(_) => capable.push(peer_id.clone()),
918 Err(e) => {
919 warn!(peer_id = %peer_id, error = %e, "Skipping corrupted capability record");
920 }
921 },
922 Ok(None) => {}
923 Err(e) => {
924 warn!(peer_id = %peer_id, error = %e, "Failed to load capability record");
925 }
926 }
927 }
928 let restored = capable.len() as u32;
929 for peer_id in capable {
930 // Read back from the category, so the record demonstrably exists:
931 // seeding the elision cache here is what keeps the first verified
932 // frame from a restored peer from rewriting a record that is
933 // already on disk.
934 self.encryption_capable_persisted.insert(peer_id.clone());
935 self.mark_encryption_capable(&peer_id);
936 }
937 if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
938 warn!(
939 listed,
940 cap = MAX_RESTORE_KEYS_PER_CATEGORY,
941 "Capability store listed more peers than any legitimate run can produce; ignoring \
942 the tail rather than pruning it, since a dropped record re-opens the plaintext gate"
943 );
944 }
945 if restored > 0 {
946 info!(
947 count = restored,
948 "Restored encryption-capability records from storage"
949 );
950 }
951 }
952}