Skip to main content

offline_protocol/protocol/
security.rs

1//! Control message signing, verification, and peer identity derivation.
2
3use super::storage::MAX_RESTORE_KEYS_PER_CATEGORY;
4use super::{
5    base64_decode, base64_encode, 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}