Skip to main content

sipx_transport/
capture.rs

1//! Recording the signalling an endpoint exchanged, for attaching to a bug report.
2//!
3//! `docs/specs/sip-transport.md` §13. Off by default; when off, the cost is one `Option` check per
4//! message and nothing is opened, allocated or spawned.
5//!
6//! # The two things worth knowing before reading the code
7//!
8//! **Ordering is established in the driver loop; the write is not performed there** (§13.2). The
9//! loop stamps each record with a sequence number and a timestamp and hands it to a writer over a
10//! bounded channel. Writing inline reads as the more faithful design and is the opposite: the loop
11//! that would do the writing is the loop that fires retransmission timers, so an inline write puts
12//! the filesystem in the retransmission path and delays Timer A on a slow or full disk — which is
13//! the "observation that perturbs a retransmission race" the story forbids. Faithfulness comes from
14//! the order being *decided* at the observation point, which the sequence number records; the writer
15//! may fall behind but cannot reorder what it was given.
16//!
17//! **A capture is a security surface.** It is written to be handed to someone outside the trust
18//! boundary it was recorded in, so redaction removes the secrets that would still be valid in
19//! another person's hands: digest responses, opaque `Bearer` and `Basic` tokens, SRTP master keys,
20//! push tokens, instance URNs. What it cannot remove is identity — `To`, `From` and the SDP
21//! addresses survive, and they are enough to say who called whom and from where. Redaction makes a
22//! capture safe to *attach*, not safe to publish. See §13.3, and the section comment further down
23//! for why the scan is structural rather than a header-name prefix.
24//!
25//! Redaction is internal on purpose: it is a policy that grows as new credential-bearing headers are
26//! registered, and publishing it would make each addition a breaking change under `A-8`.
27
28use std::io::Write;
29use std::net::{IpAddr, SocketAddr};
30use std::path::{Path, PathBuf};
31use std::sync::Arc;
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::time::{SystemTime, UNIX_EPOCH};
34
35use bytes::Bytes;
36
37use crate::counters::Meters;
38use crate::target::TransportKind;
39
40/// Best-effort export of the existing signalling capture as HEP3 datagrams.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct HepConfig {
43    /// UDP collector receiving one HEP3 datagram per captured SIP message.
44    pub collector: SocketAddr,
45    /// Capture-agent identifier carried in HEP chunk `0x000c`.
46    pub capture_id: u32,
47}
48
49impl HepConfig {
50    /// A collector and the stable capture-agent id assigned to this endpoint.
51    #[must_use]
52    pub const fn new(collector: SocketAddr, capture_id: u32) -> Self {
53        Self {
54            collector,
55            capture_id,
56        }
57    }
58}
59
60/// How a capture is configured (`Config::capture`).
61#[derive(Debug, Clone)]
62pub struct CaptureConfig {
63    /// Where to write the pcapng file. Created, or truncated if it exists.
64    pub path: PathBuf,
65    /// Whether to strip credentials before writing (§13.3). **On by default.**
66    ///
67    /// Turning it off is for a lab capture against a test registrar, where there is no secret worth
68    /// removing and redaction would hide the digest bug the capture was taken to find. Never turn it
69    /// off for a capture that will leave the machine.
70    pub redact: bool,
71    /// How many records may queue for the writer before records are dropped.
72    ///
73    /// Dropped rather than blocking the driver: see the module note. A dropped record is counted in
74    /// [`crate::CaptureCounts::dropped`], never silent.
75    pub queue: usize,
76    /// Optional HEP3 export of the same redacted records written to the pcapng file.
77    ///
78    /// HEP is always best effort: the collector socket is non-blocking and failures increment
79    /// [`crate::CaptureCounts::hep_dropped`] without failing the endpoint or the call. A HEP
80    /// export may not be combined with [`Self::without_redaction`].
81    pub hep: Option<HepConfig>,
82}
83
84impl CaptureConfig {
85    /// A redacting capture at `path`, with a queue deep enough for a burst.
86    #[must_use]
87    pub fn new(path: impl Into<PathBuf>) -> Self {
88        Self {
89            path: path.into(),
90            redact: true,
91            queue: 1024,
92            hep: None,
93        }
94    }
95
96    /// Send each redacted capture record to a HEP3 collector as well as the pcapng file.
97    #[must_use]
98    pub fn with_hep(mut self, hep: HepConfig) -> Self {
99        self.hep = Some(hep);
100        self
101    }
102
103    /// Keep credentials in the file.
104    ///
105    /// Named so that it cannot be reached without saying what it does at the call site.
106    #[must_use]
107    pub fn without_redaction(mut self) -> Self {
108        self.redact = false;
109        self
110    }
111}
112
113/// Which way a message was going.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum Direction {
116    /// Received by this endpoint.
117    In,
118    /// Sent by this endpoint.
119    Out,
120}
121
122impl Direction {
123    const fn as_str(self) -> &'static str {
124        match self {
125            Self::In => "in",
126            Self::Out => "out",
127        }
128    }
129}
130
131/// One observed message, stamped at the point it crossed the boundary.
132#[derive(Debug)]
133struct Record {
134    /// The order the loop saw it in. This, and not the write, is what makes the capture faithful.
135    seq: u64,
136    at: SystemTime,
137    local: SocketAddr,
138    peer: SocketAddr,
139    transport: TransportKind,
140    direction: Direction,
141    bytes: Bytes,
142    /// Whether [`redact`] changed anything, so the comment can say so.
143    redacted: bool,
144}
145
146/// The driver's end of a running capture.
147///
148/// Cheap to check and cheap to hold. The writer lives on its own thread; this is a bounded sender
149/// and a counter.
150#[derive(Debug)]
151pub(crate) struct Capture {
152    records: std::sync::mpsc::SyncSender<Record>,
153    /// The next sequence number, assigned in the loop.
154    seq: u64,
155    redact: bool,
156    /// Set by the writer when a write has failed, so the driver stops handing it records.
157    ///
158    /// A capture that is silently not happening is the same failure as a silent discard, one level
159    /// up (§13.2), so the failure is counted and logged by the writer before this is set.
160    failed: Arc<AtomicBool>,
161}
162
163impl Capture {
164    /// Open `path` and start the writer thread.
165    ///
166    /// Fails only if the file cannot be created or its headers cannot be written — a
167    /// misconfiguration the caller should hear about at `bind` rather than discover in an empty
168    /// file later.
169    pub(crate) fn start(config: &CaptureConfig, meters: Arc<Meters>) -> std::io::Result<Self> {
170        let file = std::fs::File::create(&config.path)?;
171        let mut writer = std::io::BufWriter::new(file);
172        write_section_header(&mut writer)?;
173        writer.flush()?;
174
175        // Bounded, and `try_send` at the other end: an overrun drops a record rather than blocking
176        // the driver. `sync_channel` rather than a tokio channel because the consumer is a plain
177        // thread doing blocking file writes — which is the point of it not being on the loop.
178        let (records, incoming) = std::sync::mpsc::sync_channel::<Record>(config.queue.max(1));
179        let failed = Arc::new(AtomicBool::new(false));
180        let flag = Arc::clone(&failed);
181        let path = config.path.clone();
182        let hep = config.hep;
183
184        std::thread::Builder::new()
185            .name("sipx-capture".to_owned())
186            .spawn(move || {
187                write_loop(&mut writer, &incoming, &meters, &flag, &path, hep);
188            })?;
189
190        Ok(Self {
191            records,
192            seq: 0,
193            redact: config.redact,
194            failed,
195        })
196    }
197
198    /// Whether this capture has given up. Checked before a record is built, so a failed capture
199    /// costs no redaction work.
200    fn is_failed(&self) -> bool {
201        self.failed.load(Ordering::Relaxed)
202    }
203}
204
205/// Take one record off the queue at a time and write it, until the driver is gone or a write fails.
206fn write_loop(
207    writer: &mut std::io::BufWriter<std::fs::File>,
208    incoming: &std::sync::mpsc::Receiver<Record>,
209    meters: &Meters,
210    failed: &AtomicBool,
211    path: &Path,
212    hep: Option<HepConfig>,
213) {
214    let mut hep = hep.map(HepExporter::new);
215    while let Ok(record) = incoming.recv() {
216        if let Err(error) = write_packet(writer, &record).and_then(|()| writer.flush()) {
217            // Once, and then stop. A capture that cannot be written is over; continuing would log
218            // per message and produce a file nobody can trust.
219            meters.capture_error();
220            failed.store(true, Ordering::Relaxed);
221            tracing::error!(
222                %error,
223                path = %path.display(),
224                "capture write failed; the capture is now off"
225            );
226            return;
227        }
228        if let Some(exporter) = hep.as_mut() {
229            exporter.export(&record, meters);
230        }
231    }
232    // The driver dropped the sender: an ordinary shutdown. Flush what is buffered so the last
233    // messages before the shutdown are in the file, which is usually the interesting part.
234    if let Err(error) = writer.flush() {
235        meters.capture_error();
236        tracing::warn!(%error, path = %path.display(), "capture could not be flushed at shutdown");
237    }
238}
239
240// ---------------------------------------------------------------------------------------------
241// HEP3 (`docs/specs/observability-export.md` §3)
242// ---------------------------------------------------------------------------------------------
243
244/// One non-blocking UDP HEP sink owned by the existing capture writer thread.
245struct HepExporter {
246    socket: Option<std::net::UdpSocket>,
247    config: HepConfig,
248    /// A collector outage can affect every message. Warn once, then retain per-message counts and
249    /// debug lines rather than flooding the application's logs.
250    warned: bool,
251}
252
253impl HepExporter {
254    fn new(config: HepConfig) -> Self {
255        let bind = if config.collector.is_ipv4() {
256            "0.0.0.0:0"
257        } else {
258            "[::]:0"
259        };
260        let socket = std::net::UdpSocket::bind(bind)
261            .and_then(|socket| {
262                socket.connect(config.collector)?;
263                socket.set_nonblocking(true)?;
264                Ok(socket)
265            })
266            .map_err(|error| {
267                tracing::warn!(
268                    %error,
269                    collector = %config.collector,
270                    "HEP collector is unavailable; signalling capture will continue locally"
271                );
272            })
273            .ok();
274        let warned = socket.is_none();
275        Self {
276            socket,
277            config,
278            warned,
279        }
280    }
281
282    fn export(&mut self, record: &Record, meters: &Meters) {
283        let sent = encode_hep(record, self.config.capture_id).and_then(|datagram| {
284            let socket = self.socket.as_ref().ok_or_else(|| {
285                std::io::Error::new(
286                    std::io::ErrorKind::NotConnected,
287                    "HEP collector socket is unavailable",
288                )
289            })?;
290            let written = socket.send(&datagram)?;
291            if written == datagram.len() {
292                Ok(())
293            } else {
294                Err(std::io::Error::new(
295                    std::io::ErrorKind::WriteZero,
296                    "HEP datagram was not sent in full",
297                ))
298            }
299        });
300        match sent {
301            Ok(()) => meters.capture_hep_record(),
302            Err(error) => {
303                meters.capture_hep_drop();
304                if self.warned {
305                    tracing::debug!(
306                        %error,
307                        collector = %self.config.collector,
308                        "dropping HEP signalling export"
309                    );
310                } else {
311                    self.warned = true;
312                    tracing::warn!(
313                        %error,
314                        collector = %self.config.collector,
315                        "dropping HEP signalling export; calls and local capture continue"
316                    );
317                }
318            }
319        }
320    }
321}
322
323fn hep_chunk(out: &mut Vec<u8>, kind: u16, value: &[u8]) -> std::io::Result<()> {
324    let length = u16::try_from(6usize.saturating_add(value.len()))
325        .map_err(|_| std::io::Error::other("HEP chunk is too large"))?;
326    out.extend_from_slice(&0u16.to_be_bytes());
327    out.extend_from_slice(&kind.to_be_bytes());
328    out.extend_from_slice(&length.to_be_bytes());
329    out.extend_from_slice(value);
330    Ok(())
331}
332
333fn encode_hep(record: &Record, capture_id: u32) -> std::io::Result<Vec<u8>> {
334    let (source, destination) = match record.direction {
335        Direction::In => (record.peer, record.local),
336        Direction::Out => (record.local, record.peer),
337    };
338    let protocol = match record.transport {
339        TransportKind::Udp | TransportKind::Quic => 17,
340        TransportKind::Tcp | TransportKind::Tls | TransportKind::Ws | TransportKind::Wss => 6,
341    };
342    let mut ordered = Vec::with_capacity(record.bytes.len().saturating_add(128));
343    match (source.ip(), destination.ip()) {
344        (IpAddr::V4(from), IpAddr::V4(to)) => {
345            hep_chunk(&mut ordered, 0x0001, &[2])?;
346            hep_chunk(&mut ordered, 0x0002, &[protocol])?;
347            hep_chunk(&mut ordered, 0x0003, &from.octets())?;
348            hep_chunk(&mut ordered, 0x0004, &to.octets())?;
349        }
350        (IpAddr::V6(from), IpAddr::V6(to)) => {
351            hep_chunk(&mut ordered, 0x0001, &[10])?;
352            hep_chunk(&mut ordered, 0x0002, &[protocol])?;
353            hep_chunk(&mut ordered, 0x0005, &from.octets())?;
354            hep_chunk(&mut ordered, 0x0006, &to.octets())?;
355        }
356        _ => {
357            return Err(std::io::Error::other(
358                "HEP endpoints use different IP families",
359            ));
360        }
361    }
362    hep_chunk(&mut ordered, 0x0007, &source.port().to_be_bytes())?;
363    hep_chunk(&mut ordered, 0x0008, &destination.port().to_be_bytes())?;
364    let since = record.at.duration_since(UNIX_EPOCH).unwrap_or_default();
365    let seconds = u32::try_from(since.as_secs() & u64::from(u32::MAX)).unwrap_or(0);
366    hep_chunk(&mut ordered, 0x0009, &seconds.to_be_bytes())?;
367    hep_chunk(&mut ordered, 0x000a, &since.subsec_micros().to_be_bytes())?;
368    hep_chunk(&mut ordered, 0x000b, &[1])?;
369    hep_chunk(&mut ordered, 0x000c, &capture_id.to_be_bytes())?;
370    hep_chunk(&mut ordered, 0x000f, &record.bytes)?;
371
372    let total = u16::try_from(6usize.saturating_add(ordered.len()))
373        .map_err(|_| std::io::Error::other("HEP datagram is too large"))?;
374    let mut datagram = Vec::with_capacity(usize::from(total));
375    datagram.extend_from_slice(b"HEP3");
376    datagram.extend_from_slice(&total.to_be_bytes());
377    datagram.extend_from_slice(&ordered);
378    Ok(datagram)
379}
380
381// ---------------------------------------------------------------------------------------------
382// pcapng (§13.1)
383// ---------------------------------------------------------------------------------------------
384
385/// Section Header Block.
386const BLOCK_SECTION_HEADER: u32 = 0x0A0D_0D0A;
387/// Interface Description Block.
388const BLOCK_INTERFACE: u32 = 0x0000_0001;
389/// Enhanced Packet Block.
390const BLOCK_PACKET: u32 = 0x0000_0006;
391/// The byte-order magic, written native so a reader knows which way round the file is.
392const BYTE_ORDER_MAGIC: u32 = 0x1A2B_3C4D;
393/// Raw IP: no link layer, because there is no link layer inside a process.
394const LINKTYPE_RAW: u16 = 101;
395/// `opt_comment`.
396const OPT_COMMENT: u16 = 1;
397/// `if_tsresol`.
398const OPT_TSRESOL: u16 = 9;
399/// `opt_endofopt`.
400const OPT_END: u16 = 0;
401/// Timestamps are nanoseconds: `if_tsresol` = 9.
402const TSRESOL_NANOS: u8 = 9;
403/// IP's protocol number for UDP. Synthetic for every transport — see [`synthesise`].
404const IP_PROTO_UDP: u8 = 17;
405
406/// Pad a length up to the next multiple of four, as every pcapng block requires.
407const fn padded(len: usize) -> usize {
408    len.next_multiple_of(4)
409}
410
411/// The zero bytes that carry `len` up to the next multiple of four.
412fn padding(len: usize) -> &'static [u8] {
413    const ZEROS: [u8; 3] = [0; 3];
414    ZEROS.get(..padded(len).saturating_sub(len)).unwrap_or(&[])
415}
416
417/// Write a block: type, total length, body, total length again.
418///
419/// The trailing length is what makes a truncated file readable backwards from the end, which is
420/// §13.1's third reason for the format.
421fn write_block(out: &mut impl Write, kind: u32, body: &[u8]) -> std::io::Result<()> {
422    let total = u32::try_from(12usize.saturating_add(padded(body.len())))
423        .map_err(|_| std::io::Error::other("capture block too large"))?;
424    out.write_all(&kind.to_ne_bytes())?;
425    out.write_all(&total.to_ne_bytes())?;
426    out.write_all(body)?;
427    out.write_all(padding(body.len()))?;
428    out.write_all(&total.to_ne_bytes())
429}
430
431/// An option: code, length, value, padded.
432fn push_option(body: &mut Vec<u8>, code: u16, value: &[u8]) {
433    body.extend_from_slice(&code.to_ne_bytes());
434    let len = u16::try_from(value.len()).unwrap_or(u16::MAX);
435    body.extend_from_slice(&len.to_ne_bytes());
436    let value = value.get(..usize::from(len)).unwrap_or(value);
437    body.extend_from_slice(value);
438    body.extend_from_slice(padding(value.len()));
439}
440
441/// The Section Header Block and the one Interface Description Block.
442fn write_section_header(out: &mut impl Write) -> std::io::Result<()> {
443    let mut shb = Vec::new();
444    shb.extend_from_slice(&BYTE_ORDER_MAGIC.to_ne_bytes());
445    shb.extend_from_slice(&1u16.to_ne_bytes()); // major
446    shb.extend_from_slice(&0u16.to_ne_bytes()); // minor
447    shb.extend_from_slice(&(-1i64).to_ne_bytes()); // section length: unknown
448    push_option(&mut shb, OPT_COMMENT, b"sipx signalling capture");
449    push_option(&mut shb, OPT_END, &[]);
450    write_block(out, BLOCK_SECTION_HEADER, &shb)?;
451
452    let mut idb = Vec::new();
453    idb.extend_from_slice(&LINKTYPE_RAW.to_ne_bytes());
454    idb.extend_from_slice(&0u16.to_ne_bytes()); // reserved
455    idb.extend_from_slice(&0u32.to_ne_bytes()); // snaplen: no limit
456    push_option(&mut idb, OPT_TSRESOL, &[TSRESOL_NANOS]);
457    push_option(&mut idb, OPT_END, &[]);
458    write_block(out, BLOCK_INTERFACE, &idb)
459}
460
461/// One Enhanced Packet Block: the synthesised headers, the message, and the comment that carries
462/// the truth about the transport.
463fn write_packet(out: &mut impl Write, record: &Record) -> std::io::Result<()> {
464    let packet = synthesise(record);
465    let nanos = record
466        .at
467        .duration_since(UNIX_EPOCH)
468        .map_or(0u128, |since| since.as_nanos());
469    let timestamp = u64::try_from(nanos).unwrap_or(u64::MAX);
470
471    let len = u32::try_from(packet.len())
472        .map_err(|_| std::io::Error::other("captured message too large"))?;
473
474    let mut body = Vec::with_capacity(packet.len() + 64);
475    body.extend_from_slice(&0u32.to_ne_bytes()); // interface 0
476    body.extend_from_slice(&((timestamp >> 32) as u32).to_ne_bytes());
477    #[allow(
478        clippy::cast_possible_truncation,
479        reason = "the low half of the timestamp is exactly what this field is"
480    )]
481    body.extend_from_slice(&(timestamp as u32).to_ne_bytes());
482    body.extend_from_slice(&len.to_ne_bytes()); // captured length
483    body.extend_from_slice(&len.to_ne_bytes()); // original length
484    body.extend_from_slice(&packet);
485    body.extend_from_slice(padding(packet.len()));
486    push_option(&mut body, OPT_COMMENT, comment(record).as_bytes());
487    push_option(&mut body, OPT_END, &[]);
488
489    write_block(out, BLOCK_PACKET, &body)
490}
491
492/// What the block comment says. **This, not the packet, is the authoritative record of the
493/// transport** (§13.1): the UDP header below is synthetic whatever the message really travelled on.
494fn comment(record: &Record) -> String {
495    let mut comment = format!(
496        "seq={} dir={} transport={} local={} peer={}",
497        record.seq,
498        record.direction.as_str(),
499        record.transport.as_str(),
500        record.local,
501        record.peer,
502    );
503    if record.transport.is_secure() {
504        comment.push_str(" decrypted-in-process=yes");
505    }
506    if record.redacted {
507        comment.push_str(" redacted=yes");
508    }
509    comment
510}
511
512/// A synthetic IP and UDP header in front of the message.
513///
514/// The addresses and ports are real; the headers are not, and §13.1 says so plainly. Writing a
515/// truthful TCP header would mean inventing per-connection sequence numbers to let a tool reassemble
516/// a stream that is already framed here — one message is one packet — so the transport header is UDP
517/// for every transport and the block comment carries which it really was.
518fn synthesise(record: &Record) -> Vec<u8> {
519    let (source, destination) = match record.direction {
520        Direction::In => (record.peer, record.local),
521        Direction::Out => (record.local, record.peer),
522    };
523    let payload = &record.bytes;
524    let udp_len = u16::try_from(8usize.saturating_add(payload.len())).unwrap_or(u16::MAX);
525
526    let mut packet = Vec::with_capacity(40 + 8 + payload.len());
527    match (source.ip(), destination.ip()) {
528        (IpAddr::V4(from), IpAddr::V4(to)) => {
529            let total =
530                u16::try_from(20usize.saturating_add(usize::from(udp_len))).unwrap_or(u16::MAX);
531            let mut header = Vec::with_capacity(20);
532            header.push(0x45); // IPv4, 20-byte header
533            header.push(0); // DSCP/ECN
534            header.extend_from_slice(&total.to_be_bytes());
535            header.extend_from_slice(&0u16.to_be_bytes()); // identification
536            header.extend_from_slice(&0u16.to_be_bytes()); // flags/fragment
537            header.push(64); // TTL
538            header.push(IP_PROTO_UDP);
539            header.extend_from_slice(&0u16.to_be_bytes()); // checksum, filled in below
540            header.extend_from_slice(&from.octets());
541            header.extend_from_slice(&to.octets());
542            // Computed, unlike the UDP checksum: it covers only these twenty bytes, so it is a real
543            // checksum over what was really written rather than one over a datagram that never
544            // existed — and leaving it zero would have every tool flag every packet (§13.1).
545            let checksum = ones_complement(&header);
546            if let Some(slot) = header.get_mut(10..12) {
547                slot.copy_from_slice(&checksum.to_be_bytes());
548            }
549            packet.extend_from_slice(&header);
550        }
551        (IpAddr::V6(from), IpAddr::V6(to)) => {
552            packet.extend_from_slice(&0x6000_0000u32.to_be_bytes()); // version 6
553            packet.extend_from_slice(&udp_len.to_be_bytes()); // payload length
554            packet.push(IP_PROTO_UDP);
555            packet.push(64); // hop limit
556            packet.extend_from_slice(&from.octets());
557            packet.extend_from_slice(&to.octets());
558        }
559        // One end IPv4 and the other IPv6 cannot happen on a real socket pair; if it somehow does,
560        // the message still matters more than the headers, so it is written with no IP header at
561        // all rather than dropped or guessed at.
562        _ => {}
563    }
564
565    packet.extend_from_slice(&source.port().to_be_bytes());
566    packet.extend_from_slice(&destination.port().to_be_bytes());
567    packet.extend_from_slice(&udp_len.to_be_bytes());
568    // Zero: "not computed", which is what it is. Legal on IPv4; §13.1 records that a strict reader
569    // may flag it on IPv6, and why inventing one is worse.
570    packet.extend_from_slice(&0u16.to_be_bytes());
571    packet.extend_from_slice(payload);
572    packet
573}
574
575/// The internet checksum (RFC 1071) over an IPv4 header.
576fn ones_complement(header: &[u8]) -> u16 {
577    let mut sum: u32 = 0;
578    for pair in header.chunks(2) {
579        let high = u32::from(pair.first().copied().unwrap_or(0));
580        let low = u32::from(pair.get(1).copied().unwrap_or(0));
581        sum = sum.wrapping_add((high << 8) | low);
582    }
583    while sum >> 16 != 0 {
584        sum = (sum & 0xFFFF).wrapping_add(sum >> 16);
585    }
586    #[allow(
587        clippy::cast_possible_truncation,
588        reason = "the fold above leaves at most sixteen significant bits"
589    )]
590    let folded = sum as u16;
591    !folded
592}
593
594// ---------------------------------------------------------------------------------------------
595// Redaction (§13.3)
596// ---------------------------------------------------------------------------------------------
597//
598// # Why this reads bytes rather than a parsed message
599//
600// A UDP datagram is captured *before* parsing (§13.2), so redaction has to work on whatever a peer
601// sent — including a message the parser would reject, which is precisely where a credential turns up
602// somewhere unexpected. That decision is right and is kept.
603//
604// What it costs is that this code cannot assume one spelling. SIP's grammar permits several for the
605// same header, the parser accepts them, and a first version of this module gated on the single
606// literal `"authorization:"` — so a folded header, an `Authorization : …` with whitespace before the
607// colon, and a bare-LF message each carried a digest response into a capture file in cleartext. The
608// shape below exists to stop that class rather than those three cases:
609//
610// 1. **Lines are split on CRLF, bare LF or bare CR.** Anything else makes a malformed message one
611//    long line, and one long line matches no header name at all.
612// 2. **Continuation lines are unfolded into one logical header** before anything looks at it
613//    (RFC 3261 §7.3.1), because a fold can fall in the middle of a parameter name.
614// 3. **A header's name is the bytes before its first colon, with trailing whitespace trimmed**, not
615//    a literal prefix — HCOLON allows whitespace before the colon (§25.1).
616// 4. **A line whose name cannot be determined is redacted conservatively rather than skipped.** If
617//    the structure is not there, a credential could be anywhere, and the cost of guessing wrong is a
618//    mangled value in a capture instead of a leaked one.
619
620/// Header parameters whose values are live credentials.
621const REDACTED_PARAMS: &[&[u8]] = &[
622    // RFC 7616 §3.4: the digest response. With the nonce beside it in the same capture it is
623    // replayable, which is what makes it the one that must go.
624    b"response",
625    // RFC 7616 §3.5: the server's half of the same exchange.
626    b"nextnonce",
627    b"rspauth",
628    // RFC 8599 §4: a push token is a bearer credential for waking a device.
629    b"pn-prid",
630    b"pn-param",
631    // RFC 5626 §4.1: a stable device identifier that outlives the call.
632    b"+sip.instance",
633];
634
635/// Headers whose value is an authentication credential.
636///
637/// Separate from [`CONTACT_HEADERS`] because only these carry an auth *scheme*, and the scheme
638/// decides whether the credential is a named parameter or one opaque token.
639const AUTH_HEADERS: &[&[u8]] = &[
640    b"authorization",
641    b"proxy-authorization",
642    b"authentication-info",
643    b"proxy-authenticate",
644    b"www-authenticate",
645];
646
647/// Headers that carry credential *parameters* without a scheme. `m` is `Contact`'s compact form.
648const CONTACT_HEADERS: &[&[u8]] = &[b"contact", b"m"];
649
650/// Schemes whose credential is one opaque token rather than named parameters.
651///
652/// RFC 8898 registers `Bearer` for SIP, and `Basic` — removed from SIP by RFC 3261 §22.1 — is still
653/// what a misconfigured gateway sends. In both the token *is* the credential, so there is no
654/// parameter to find and the whole of it goes. An unrecognised scheme whose value carries no `=` is
655/// treated the same way, because a token68 is the only other thing it can be.
656const OPAQUE_SCHEMES: &[&[u8]] = &[b"bearer", b"basic"];
657
658/// What a redacted value is replaced with.
659const REDACTION: &[u8] = b"REDACTED";
660
661/// One physical line and the terminator that ended it.
662///
663/// The terminator is carried rather than normalised because the **body** must keep its exact byte
664/// length: `Content-Length` counts it, and rewriting a bare LF inside an SDP body as CRLF would leave
665/// every message in the capture inconsistent with its own header.
666struct Line<'a> {
667    text: &'a [u8],
668    terminator: &'a [u8],
669}
670
671/// Split a message on any of the three terminators one arrives with.
672///
673/// RFC 3261 §7 says CRLF, and §13.2 promises a malformed message is captured anyway — so a peer that
674/// sends bare LF must not thereby switch redaction off.
675fn lines(message: &[u8]) -> Vec<Line<'_>> {
676    let mut out = Vec::new();
677    let mut start = 0usize;
678    let mut at = 0usize;
679    while at < message.len() {
680        let width = match message.get(at) {
681            Some(b'\r') if message.get(at.saturating_add(1)) == Some(&b'\n') => 2,
682            Some(b'\r' | b'\n') => 1,
683            _ => 0,
684        };
685        if width == 0 {
686            at = at.saturating_add(1);
687            continue;
688        }
689        let end = at.saturating_add(width);
690        out.push(Line {
691            text: message.get(start..at).unwrap_or(&[]),
692            terminator: message.get(at..end).unwrap_or(&[]),
693        });
694        at = end;
695        start = end;
696    }
697    if start < message.len() {
698        out.push(Line {
699            text: message.get(start..).unwrap_or(&[]),
700            terminator: &[],
701        });
702    }
703    out
704}
705
706fn is_wsp(byte: u8) -> bool {
707    byte == b' ' || byte == b'\t'
708}
709
710/// Whether a line is a continuation of the header above it (RFC 3261 §7.3.1).
711fn is_continuation(line: &[u8]) -> bool {
712    line.first().copied().is_some_and(is_wsp)
713}
714
715/// Join a header and its continuation lines into one logical line.
716///
717/// §7.3.1 makes a fold equivalent to a single space, which is what it is replaced with. Unfolding has
718/// to happen before anything reads the line: a fold may fall inside a parameter name, so
719/// `respo\r\n nse="…"` is a `response` parameter and matches nothing until it is joined up.
720fn unfold(physical: &[Line<'_>], from: usize, to: usize, separator: &[u8]) -> Vec<u8> {
721    let mut logical = Vec::new();
722    for index in from..to {
723        let Some(line) = physical.get(index) else {
724            continue;
725        };
726        if index == from {
727            logical.extend_from_slice(line.text);
728        } else {
729            logical.extend_from_slice(separator);
730            logical.extend_from_slice(line.text.trim_ascii_start());
731        }
732    }
733    logical
734}
735
736/// A header's name, lowercased — the bytes before the first colon with trailing whitespace trimmed.
737///
738/// `None` when there is no colon, which means this is not a line whose name can be established.
739fn header_name(line: &[u8]) -> Option<Vec<u8>> {
740    let at = line.iter().position(|byte| *byte == b':')?;
741    Some(line.get(..at)?.trim_ascii_end().to_ascii_lowercase())
742}
743
744/// Strip the secrets §13.3 names.
745///
746/// Returns `None` when nothing was found, so an unredacted message is never copied.
747pub(crate) fn redact(message: &[u8]) -> Option<Bytes> {
748    let physical = lines(message);
749    let mut out: Vec<u8> = Vec::with_capacity(message.len().saturating_add(16));
750    let mut changed = false;
751    let mut in_body = false;
752    let mut index = 0usize;
753
754    while index < physical.len() {
755        let Some(line) = physical.get(index) else {
756            break;
757        };
758
759        if in_body {
760            // Length-preserving, because `Content-Length` counts these bytes.
761            match redact_body_line(line.text) {
762                Some(redacted) => {
763                    changed = true;
764                    out.extend_from_slice(&redacted);
765                }
766                None => out.extend_from_slice(line.text),
767            }
768            out.extend_from_slice(line.terminator);
769            index = index.saturating_add(1);
770            continue;
771        }
772
773        // The empty line ends the headers. Its own terminator is part of the separator and is copied
774        // through unchanged.
775        if line.text.is_empty() {
776            in_body = true;
777            out.extend_from_slice(line.terminator);
778            index = index.saturating_add(1);
779            continue;
780        }
781
782        // This line plus any continuation of it are one header.
783        let mut end = index.saturating_add(1);
784        while physical
785            .get(end)
786            .is_some_and(|next| is_continuation(next.text))
787        {
788            end = end.saturating_add(1);
789        }
790
791        // §7.3.1 makes a fold equivalent to a single space, so that is the reading a parser gets and
792        // the one tried first. If it finds nothing and the header *was* folded, the fold is removed
793        // entirely and the line is scanned again: a fold inside a token names no parameter in SIP, but
794        // "no parser would read that as a credential" is a worse thing to be wrong about than one
795        // extra scan of a rare line. Fail safe on spellings — that is the whole lesson of this module.
796        let redacted = redact_header(&unfold(&physical, index, end, b" ")).or_else(|| {
797            (end.saturating_sub(index) > 1)
798                .then(|| redact_header(&unfold(&physical, index, end, b"")))
799                .flatten()
800        });
801        match redacted {
802            Some(redacted) => {
803                changed = true;
804                // Emitted unfolded: the fold is equivalent to a space (§7.3.1), and a redacted
805                // record is not byte-exact in any case (§13.3).
806                out.extend_from_slice(&redacted);
807                out.extend_from_slice(b"\r\n");
808            }
809            None => {
810                // Untouched, so the original bytes go through exactly — folds, terminators and all.
811                for at in index..end {
812                    if let Some(original) = physical.get(at) {
813                        out.extend_from_slice(original.text);
814                        out.extend_from_slice(original.terminator);
815                    }
816                }
817            }
818        }
819        index = end;
820    }
821
822    changed.then(|| Bytes::from(out))
823}
824
825/// Redact one logical header line, if it is one that can carry a secret.
826fn redact_header(line: &[u8]) -> Option<Vec<u8>> {
827    match header_name(line) {
828        Some(name) if AUTH_HEADERS.contains(&name.as_slice()) => redact_auth_header(line),
829        Some(name) if CONTACT_HEADERS.contains(&name.as_slice()) => redact_params(line, false),
830        // A named header that carries no credential: a `From` display name reading `response=me` is
831        // not a credential and is left alone.
832        Some(_) => None,
833        // No colon, so there is no name to go on. Redact conservatively: this is the malformed case,
834        // and being wrong costs a mangled value rather than a leaked one.
835        None => redact_params(line, false),
836    }
837}
838
839/// Redact an authentication header, whichever shape its scheme gives it.
840fn redact_auth_header(line: &[u8]) -> Option<Vec<u8>> {
841    let colon = line.iter().position(|byte| *byte == b':')?;
842    let after_colon = colon.saturating_add(1);
843    let value = line.get(after_colon..).unwrap_or(&[]);
844
845    // The scheme is the first token of the value; the credential is whatever follows it.
846    let lead = value
847        .iter()
848        .position(|byte| !is_wsp(*byte))
849        .unwrap_or(value.len());
850    let token = value.get(lead..).unwrap_or(&[]);
851    let width = token
852        .iter()
853        .position(|byte| is_wsp(*byte))
854        .unwrap_or(token.len());
855    let scheme = token.get(..width).unwrap_or(&[]).to_ascii_lowercase();
856    let rest_at = after_colon.saturating_add(lead).saturating_add(width);
857    let rest = line.get(rest_at..).unwrap_or(&[]);
858
859    let opaque = OPAQUE_SCHEMES.contains(&scheme.as_slice())
860        // An unrecognised scheme whose credential carries no `=` has no parameter to find, so the
861        // credential is the token itself. Fail safe rather than leave it.
862        || (!scheme.is_empty() && !rest.trim_ascii().is_empty() && !rest.contains(&b'='));
863
864    if opaque {
865        let mut redacted = Vec::with_capacity(line.len());
866        redacted.extend_from_slice(line.get(..rest_at).unwrap_or(&[]));
867        redacted.push(b' ');
868        redacted.extend_from_slice(REDACTION);
869        return Some(redacted);
870    }
871    redact_params(line, false)
872}
873
874/// Replace every credential parameter on a line.
875///
876/// `preserve_len` keeps each replacement the same width as what it replaced, which the body needs and
877/// a header does not — see [`redact_body_line`].
878fn redact_params(line: &[u8], preserve_len: bool) -> Option<Vec<u8>> {
879    let mut out = line.to_vec();
880    let mut changed = false;
881    for name in REDACTED_PARAMS {
882        while let Some(replaced) = redact_param(&out, name, preserve_len) {
883            out = replaced;
884            changed = true;
885        }
886    }
887    changed.then_some(out)
888}
889
890/// The bytes a redacted value is replaced with.
891fn replacement(width: usize, preserve_len: bool) -> Vec<u8> {
892    if !preserve_len {
893        return REDACTION.to_vec();
894    }
895    let mut padded = Vec::with_capacity(width);
896    padded.extend_from_slice(REDACTION.get(..width.min(REDACTION.len())).unwrap_or(&[]));
897    while padded.len() < width {
898        padded.push(b'X');
899    }
900    padded
901}
902
903/// Replace the first not-yet-redacted `name=value` on a line.
904///
905/// Returns `None` once there is nothing left to do, which is what terminates the caller's loop.
906fn redact_param(line: &[u8], name: &[u8], preserve_len: bool) -> Option<Vec<u8>> {
907    let mut from = 0usize;
908    loop {
909        let at = find_ci(line, name, from)?;
910        let before_ok = at == 0
911            || line
912                .get(at.wrapping_sub(1))
913                .is_some_and(|byte| matches!(byte, b',' | b';' | b' ' | b'\t' | b'"' | b'='));
914        // Past the name, optional whitespace, then `=`.
915        let mut cursor = at.saturating_add(name.len());
916        while line.get(cursor).is_some_and(u8::is_ascii_whitespace) {
917            cursor = cursor.saturating_add(1);
918        }
919        if !before_ok || line.get(cursor) != Some(&b'=') {
920            from = at.saturating_add(1);
921            continue;
922        }
923        cursor = cursor.saturating_add(1);
924        while line.get(cursor).is_some_and(u8::is_ascii_whitespace) {
925            cursor = cursor.saturating_add(1);
926        }
927
928        let quoted = line.get(cursor) == Some(&b'"');
929        let value_start = if quoted {
930            cursor.saturating_add(1)
931        } else {
932            cursor
933        };
934        let mut end = value_start;
935        while let Some(&byte) = line.get(end) {
936            if quoted {
937                // A quoted-pair escapes the next octet, including a quote (RFC 3261 §25.1), so the
938                // string does not end here and the escaped byte is part of the value.
939                if byte == b'\\' && line.get(end.saturating_add(1)).is_some() {
940                    end = end.saturating_add(2);
941                    continue;
942                }
943                if byte == b'"' {
944                    break;
945                }
946            } else if matches!(byte, b',' | b';' | b' ' | b'\t') {
947                break;
948            }
949            end = end.saturating_add(1);
950        }
951
952        let value = line.get(value_start..end).unwrap_or(&[]);
953        // Already done, or nothing there to do. Without the first the caller's loop would not
954        // terminate; the second keeps a message that had nothing to hide from being rewritten.
955        if value == replacement(value.len(), preserve_len).as_slice() || value.is_empty() {
956            from = end.max(at.saturating_add(1));
957            continue;
958        }
959
960        let mut out = Vec::with_capacity(line.len());
961        out.extend_from_slice(line.get(..value_start).unwrap_or(&[]));
962        out.extend_from_slice(&replacement(value.len(), preserve_len));
963        out.extend_from_slice(line.get(end..).unwrap_or(&[]));
964        return Some(out);
965    }
966}
967
968/// Redact a line of a message body.
969///
970/// **Length-preserving, unlike a header**, and that is not fussiness: the body's length is declared in
971/// `Content-Length`, so shortening a line here would leave every message in the capture inconsistent
972/// with its own header and unparseable by the tool the capture exists to be read in.
973fn redact_body_line(line: &[u8]) -> Option<Vec<u8>> {
974    if starts_with_ci(line, b"a=crypto:") {
975        return redact_inline_keys(line);
976    }
977    if starts_with_ci(line, b"k=") {
978        return redact_sdp_key(line);
979    }
980    // A SIP message nested in a body — `message/sipfrag` (RFC 3420), or a part of a multipart — puts
981    // real headers where this function sees body lines. Handled by name, like any other header, but
982    // length-preserving because it is inside the body either way.
983    match header_name(line) {
984        Some(name)
985            if AUTH_HEADERS.contains(&name.as_slice())
986                || CONTACT_HEADERS.contains(&name.as_slice()) =>
987        {
988            redact_params(line, true)
989        }
990        _ => None,
991    }
992}
993
994/// Redact every `inline:` key on an `a=crypto` line (RFC 4568 §6.1).
995///
996/// Every one, because `key-params = key-param *(";" key-param)` (§9.1) permits more than one and a
997/// single-occurrence search left the second key in the file.
998fn redact_inline_keys(line: &[u8]) -> Option<Vec<u8>> {
999    const INLINE: &[u8] = b"inline:";
1000    let mut out: Vec<u8> = Vec::with_capacity(line.len());
1001    let mut cursor = 0usize;
1002    let mut changed = false;
1003
1004    while let Some(found) = find_from(line, INLINE, cursor) {
1005        let value_start = found.saturating_add(INLINE.len());
1006        let mut end = value_start;
1007        while let Some(&byte) = line.get(end) {
1008            // The key runs to the `|` that begins the optional lifetime or MKI, or to the end of the
1009            // parameter. Neither of those is secret and both are kept.
1010            if matches!(byte, b'|' | b' ' | b'\t' | b';') {
1011                break;
1012            }
1013            end = end.saturating_add(1);
1014        }
1015        let width = end.saturating_sub(value_start);
1016        out.extend_from_slice(line.get(cursor..value_start).unwrap_or(&[]));
1017        if width > 0 {
1018            out.extend_from_slice(&replacement(width, true));
1019            changed = true;
1020        }
1021        cursor = end;
1022    }
1023    out.extend_from_slice(line.get(cursor..).unwrap_or(&[]));
1024    changed.then_some(out)
1025}
1026
1027/// Redact an SDP `k=` key (RFC 4566 §5.12).
1028///
1029/// Deprecated by the RFC itself and still a key in cleartext when it appears. `k=<method>:<key>` —
1030/// the method is kept, the key goes. `k=prompt` carries no key and is left alone.
1031fn redact_sdp_key(line: &[u8]) -> Option<Vec<u8>> {
1032    let colon = line.iter().position(|byte| *byte == b':')?;
1033    let value_start = colon.saturating_add(1);
1034    let width = line.len().saturating_sub(value_start);
1035    if width == 0 {
1036        return None;
1037    }
1038    let mut out = Vec::with_capacity(line.len());
1039    out.extend_from_slice(line.get(..value_start).unwrap_or(&[]));
1040    out.extend_from_slice(&replacement(width, true));
1041    Some(out)
1042}
1043
1044/// Case-sensitive substring search from `from`.
1045fn find_from(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
1046    if needle.is_empty() || haystack.len() < needle.len() {
1047        return None;
1048    }
1049    (from..=haystack.len().saturating_sub(needle.len()))
1050        .find(|&at| haystack.get(at..at.saturating_add(needle.len())) == Some(needle))
1051}
1052
1053/// Case-insensitive substring search from `from`.
1054fn find_ci(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
1055    if needle.is_empty() || haystack.len() < needle.len() {
1056        return None;
1057    }
1058    (from..=haystack.len().saturating_sub(needle.len())).find(|&at| {
1059        haystack
1060            .get(at..at.saturating_add(needle.len()))
1061            .is_some_and(|window| window.eq_ignore_ascii_case(needle))
1062    })
1063}
1064
1065fn starts_with_ci(haystack: &[u8], prefix: &[u8]) -> bool {
1066    haystack
1067        .get(..prefix.len())
1068        .is_some_and(|window| window.eq_ignore_ascii_case(prefix))
1069}
1070
1071impl Capture {
1072    /// Stamp a message and hand it to the writer.
1073    ///
1074    /// Called from the driver loop, which is what makes the sequence number meaningful. Everything
1075    /// expensive — redaction, the synthetic headers, the write — happens after this returns or on
1076    /// another thread.
1077    /// Observe a message if a capture is running, and do **no work at all** if one is not.
1078    ///
1079    /// The laziness is a contract rather than an optimisation, which is why it lives in one function
1080    /// with a test on it instead of being a discipline at three call sites. `bytes` is a closure
1081    /// because producing them is not free on every path — an inbound stream message has to be
1082    /// re-serialised to be captured (§13.2) — and an endpoint with no capture configured must not pay
1083    /// for a file nobody asked for. The story's Acceptance says capture "costs nothing when off", and
1084    /// a version of this that took `&Bytes` made that false for every TCP, TLS and WebSocket message.
1085    pub(crate) fn observe_if_capturing(
1086        capture: Option<&mut Self>,
1087        meters: &Meters,
1088        local: SocketAddr,
1089        peer: SocketAddr,
1090        transport: TransportKind,
1091        direction: Direction,
1092        bytes: impl FnOnce() -> Bytes,
1093    ) {
1094        // Both arms of this return before `bytes` is called, which is the whole point.
1095        let Some(capture) = capture else {
1096            return;
1097        };
1098        if capture.is_failed() {
1099            return;
1100        }
1101        capture.observe(meters, &bytes(), local, peer, transport, direction);
1102    }
1103
1104    fn observe(
1105        &mut self,
1106        meters: &Meters,
1107        bytes: &Bytes,
1108        local: SocketAddr,
1109        peer: SocketAddr,
1110        transport: TransportKind,
1111        direction: Direction,
1112    ) {
1113        let (bytes, redacted) = if self.redact {
1114            match redact(bytes) {
1115                Some(clean) => (clean, true),
1116                None => (bytes.clone(), false),
1117            }
1118        } else {
1119            (bytes.clone(), false)
1120        };
1121
1122        self.seq = self.seq.saturating_add(1);
1123        let record = Record {
1124            seq: self.seq,
1125            at: SystemTime::now(),
1126            local,
1127            peer,
1128            transport,
1129            direction,
1130            bytes,
1131            redacted,
1132        };
1133        // `try_send`, never `send`: blocking here would put the writer's queue in the
1134        // retransmission path, which is the whole thing §13.2 refuses.
1135        match self.records.try_send(record) {
1136            Ok(()) => meters.capture_record(),
1137            Err(_) => meters.capture_drop(),
1138        }
1139    }
1140}
1141
1142#[cfg(test)]
1143#[allow(
1144    clippy::unwrap_used,
1145    clippy::expect_used,
1146    clippy::panic,
1147    clippy::indexing_slicing
1148)]
1149mod tests {
1150    use super::*;
1151
1152    fn text(bytes: &[u8]) -> String {
1153        String::from_utf8_lossy(bytes).into_owned()
1154    }
1155
1156    /// Vector X14. The precedent this follows is `sipx-sdp`'s: an error names the tag and never the
1157    /// key material.
1158    #[test]
1159    fn a_digest_response_is_removed_and_the_challenge_is_kept() {
1160        let message = b"REGISTER sip:example.net SIP/2.0\r\n\
1161             Authorization: Digest username=\"alice\", realm=\"example.net\", \
1162             nonce=\"abc123\", uri=\"sip:example.net\", response=\"deadbeefcafe0001\", qop=auth\r\n\
1163             Content-Length: 0\r\n\r\n";
1164        let redacted = redact(message).expect("a digest response must be redacted");
1165        let out = text(&redacted);
1166
1167        assert!(
1168            !out.contains("deadbeefcafe0001"),
1169            "the digest response is still in the capture: {out}"
1170        );
1171        assert!(out.contains("response=\"REDACTED\""), "{out}");
1172        // Kept, and deliberately: a digest failure is unreadable without them, and a nonce with no
1173        // response beside it is not a credential.
1174        assert!(out.contains("realm=\"example.net\""), "{out}");
1175        assert!(out.contains("nonce=\"abc123\""), "{out}");
1176        assert!(out.contains("username=\"alice\""), "{out}");
1177        assert!(out.contains("qop=auth"), "{out}");
1178        assert!(
1179            out.starts_with("REGISTER sip:example.net SIP/2.0\r\n"),
1180            "{out}"
1181        );
1182    }
1183
1184    /// Vector X15, and the length-preservation rule that keeps `Content-Length` honest.
1185    #[test]
1186    fn an_srtp_key_is_removed_without_changing_the_body_length() {
1187        let message = b"INVITE sip:bob@example.net SIP/2.0\r\n\
1188             Content-Type: application/sdp\r\n\
1189             Content-Length: 68\r\n\r\n\
1190             v=0\r\n\
1191             a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:d0RmdmcmVCspeEc3QGZiNWpVLFJhQX1cfHAwJSoj|2^20|1:32\r\n";
1192        let redacted = redact(message).expect("an SRTP key must be redacted");
1193        let out = text(&redacted);
1194
1195        assert!(
1196            !out.contains("d0RmdmcmVCspeEc3QGZiNWpVLFJhQX1cfHAwJSoj"),
1197            "the SRTP master key is still in the capture: {out}"
1198        );
1199        assert!(
1200            out.contains("a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:"),
1201            "{out}"
1202        );
1203        // The tag and the crypto-suite are kept: §5.1.2 makes them the thing an answer has to echo,
1204        // so a negotiation bug is invisible without them.
1205        assert!(out.contains("AES_CM_128_HMAC_SHA1_80"), "{out}");
1206        assert!(
1207            out.contains("|2^20|1:32"),
1208            "the lifetime and MKI are not secret: {out}"
1209        );
1210        assert_eq!(
1211            redacted.len(),
1212            message.len(),
1213            "body redaction must preserve length or Content-Length becomes a lie"
1214        );
1215    }
1216
1217    #[test]
1218    fn a_push_token_and_an_instance_urn_are_removed() {
1219        let message = b"REGISTER sip:example.net SIP/2.0\r\n\
1220             Contact: <sip:alice@192.0.2.4>;+sip.instance=\"<urn:uuid:00000000-0000-0000-0000-00000000042>\";\
1221             pn-provider=apns;pn-prid=SECRETTOKEN;pn-param=SECRETPARAM\r\n\
1222             Content-Length: 0\r\n\r\n";
1223        let out = text(&redact(message).expect("push credentials must be redacted"));
1224
1225        assert!(!out.contains("SECRETTOKEN"), "{out}");
1226        assert!(!out.contains("SECRETPARAM"), "{out}");
1227        assert!(!out.contains("urn:uuid:00000000"), "{out}");
1228        // The provider is not a credential and says which push service a bug is about.
1229        assert!(out.contains("pn-provider=apns"), "{out}");
1230        assert!(out.contains("sip:alice@192.0.2.4"), "{out}");
1231    }
1232
1233    /// What redaction deliberately keeps. Stated as a test because §13's disclosure depends on it
1234    /// being true: an operator told the file is safe would be worse off than one told it is not.
1235    #[test]
1236    fn redaction_keeps_what_makes_a_message_diagnosable() {
1237        let message = b"INVITE sip:bob@example.net SIP/2.0\r\n\
1238             Via: SIP/2.0/UDP 192.0.2.4:5060;branch=z9hG4bKtrace\r\n\
1239             From: \"Alice Example\" <sip:alice@example.net>;tag=abcd\r\n\
1240             To: <sip:bob@example.net>\r\n\
1241             Call-ID: trace@sipx\r\n\
1242             CSeq: 1 INVITE\r\n\
1243             Authorization: Digest response=\"secret\"\r\n\
1244             Content-Length: 0\r\n\r\n";
1245        let out = text(&redact(message).expect("the digest response is redacted"));
1246
1247        for kept in [
1248            "z9hG4bKtrace",
1249            "Alice Example",
1250            "sip:alice@example.net",
1251            "sip:bob@example.net",
1252            "trace@sipx",
1253            "CSeq: 1 INVITE",
1254        ] {
1255            assert!(
1256                out.contains(kept),
1257                "redaction removed {kept}, which it needs: {out}"
1258            );
1259        }
1260        assert!(!out.contains("\"secret\""), "{out}");
1261    }
1262
1263    /// A message with no secret in it is not copied at all, which is what keeps redaction off the
1264    /// cost of an ordinary capture.
1265    #[test]
1266    fn a_message_with_no_credential_is_left_untouched() {
1267        let message = b"OPTIONS sip:example.net SIP/2.0\r\n\
1268             To: <sip:example.net>\r\n\
1269             Content-Length: 0\r\n\r\n";
1270        assert!(
1271            redact(message).is_none(),
1272            "nothing to redact must mean no copy"
1273        );
1274    }
1275
1276    /// A `To` display name that happens to contain a parameter name must not be mangled: redaction
1277    /// is by header *and* by parameter, not by substring.
1278    #[test]
1279    fn a_display_name_that_looks_like_a_parameter_survives() {
1280        let message = b"INVITE sip:bob@example.net SIP/2.0\r\n\
1281             From: \"response=me\" <sip:alice@example.net>\r\n\
1282             Content-Length: 0\r\n\r\n";
1283        assert!(
1284            redact(message).is_none(),
1285            "a From display name is not a credential-bearing header"
1286        );
1287    }
1288
1289    /// Malformed input must be redacted too — it is exactly where a credential turns up somewhere
1290    /// unexpected — and must not panic (`AGENTS.md` §3).
1291    #[test]
1292    fn a_truncated_message_is_redacted_without_panicking() {
1293        assert!(redact(b"").is_none());
1294        assert!(redact(b"\r\n").is_none());
1295        assert!(redact(b"Authorization: Digest response=").is_none());
1296        let partial = redact(b"Authorization: Digest response=\"abc")
1297            .expect("an unterminated quoted value is still a credential");
1298        assert!(!text(&partial).contains("abc"));
1299        // No trailing CRLF: the last line must still be processed.
1300        let no_crlf = redact(b"Authorization: Digest response=\"xyz\"").expect("redacted");
1301        assert!(!text(&no_crlf).contains("xyz"));
1302    }
1303
1304    /// Join lines with CRLF and end the headers, so a fixture cannot accidentally indent a line and
1305    /// thereby turn it into a folded continuation of the one above — which is a real SIP rule and was
1306    /// how the first draft of these tests fooled itself.
1307    fn message(lines: &[&str]) -> Vec<u8> {
1308        joined(lines, "\r\n")
1309    }
1310
1311    /// The same, with a chosen terminator, for the spellings that are the point of the test.
1312    fn joined(lines: &[&str], terminator: &str) -> Vec<u8> {
1313        let mut out = String::new();
1314        for line in lines {
1315            out.push_str(line);
1316            out.push_str(terminator);
1317        }
1318        out.push_str(terminator);
1319        out.into_bytes()
1320    }
1321
1322    /// **The class the security review found**: legal spellings of the same header that a literal
1323    /// `"authorization:"` prefix does not match, each of which carried a digest response into a
1324    /// capture file in cleartext. Table-driven because the class is the point, not the cases — a new
1325    /// spelling belongs here as a row.
1326    #[test]
1327    fn every_legal_spelling_of_a_credential_header_is_redacted() {
1328        const SECRET: &str = "SPELLINGSECRET0001";
1329
1330        let folded = message(&[
1331            "REGISTER sip:example.net SIP/2.0",
1332            "Authorization: Digest username=\"alice\",",
1333            "\tresponse=\"SPELLINGSECRET0001\"",
1334            "Content-Length: 0",
1335        ]);
1336        let folded_mid_name = message(&[
1337            "REGISTER sip:example.net SIP/2.0",
1338            "Authorization: Digest respo",
1339            " nse=\"SPELLINGSECRET0001\"",
1340        ]);
1341        let space_before_colon = message(&[
1342            "REGISTER sip:example.net SIP/2.0",
1343            "Authorization : Digest response=\"SPELLINGSECRET0001\"",
1344        ]);
1345        let tab_before_colon = message(&[
1346            "REGISTER sip:example.net SIP/2.0",
1347            "Authorization\t: Digest response=\"SPELLINGSECRET0001\"",
1348        ]);
1349        let bare_lf = joined(
1350            &[
1351                "REGISTER sip:example.net SIP/2.0",
1352                "Authorization: Digest response=\"SPELLINGSECRET0001\"",
1353            ],
1354            "\n",
1355        );
1356        let bare_cr = joined(
1357            &[
1358                "REGISTER sip:example.net SIP/2.0",
1359                "Authorization: Digest response=\"SPELLINGSECRET0001\"",
1360            ],
1361            "\r",
1362        );
1363        let unterminated =
1364            b"REGISTER sip:example.net SIP/2.0\r\nAuthorization: Digest response=\"SPELLINGSECRET0001\"".to_vec();
1365
1366        let cases: [(&str, &[u8]); 7] = [
1367            ("folded onto a continuation line (RFC 3261 §7.3.1)", &folded),
1368            (
1369                "folded in the middle of the parameter name",
1370                &folded_mid_name,
1371            ),
1372            (
1373                "whitespace before the colon, which HCOLON permits (§25.1)",
1374                &space_before_colon,
1375            ),
1376            ("a tab before the colon", &tab_before_colon),
1377            ("bare LF, which made the whole datagram one line", &bare_lf),
1378            ("bare CR", &bare_cr),
1379            ("no trailing terminator at all", &unterminated),
1380        ];
1381
1382        for (spelling, raw) in cases {
1383            let redacted =
1384                redact(raw).unwrap_or_else(|| panic!("{spelling}: nothing was redacted at all"));
1385            let out = text(&redacted);
1386            assert!(
1387                !out.contains(SECRET),
1388                "{spelling}: the credential survived redaction: {out}"
1389            );
1390        }
1391    }
1392
1393    /// A line whose name cannot be established is redacted conservatively rather than skipped.
1394    ///
1395    /// The fail-safe half of the fix. "Unparseable" is exactly when a credential turns up somewhere
1396    /// unexpected, so the absence of structure must not become the absence of redaction.
1397    #[test]
1398    fn a_line_with_no_header_name_is_redacted_conservatively() {
1399        let raw = message(&[
1400            "REGISTER sip:example.net SIP/2.0",
1401            "GARBAGE WITHOUT A COLON response=\"CONSERVATIVE0004\"",
1402        ]);
1403        let out = text(&redact(&raw).expect("a nameless line is still scanned"));
1404        assert!(!out.contains("CONSERVATIVE0004"), "{out}");
1405    }
1406
1407    /// **B2**: `key-params = key-param *(";" key-param)` (RFC 4568 §9.1), so one line can carry more
1408    /// than one key, and a single-occurrence search left the second in the file.
1409    #[test]
1410    fn every_inline_key_on_a_crypto_line_is_redacted() {
1411        let raw = message(&[
1412            "INVITE sip:bob@example.net SIP/2.0",
1413            "Content-Length: 0",
1414            "",
1415            "v=0",
1416            "a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:FIRSTKEY0005aaaaaaaaaaaaaaaaaaaa|2^20|1;inline:SECONDKEY0006bbbbbbbbbbbbbbbbbbb|2^20|2",
1417        ]);
1418        let redacted = redact(&raw).expect("both keys are redacted");
1419        let out = text(&redacted);
1420        assert!(
1421            !out.contains("FIRSTKEY0005"),
1422            "the first key survived: {out}"
1423        );
1424        assert!(
1425            !out.contains("SECONDKEY0006"),
1426            "the second key survived: {out}"
1427        );
1428        assert!(out.contains("|2^20|1"), "the lifetime is not secret: {out}");
1429        assert!(out.contains("|2^20|2"), "{out}");
1430        assert_eq!(
1431            redacted.len(),
1432            raw.len(),
1433            "body redaction must preserve length"
1434        );
1435    }
1436
1437    /// An opaque credential is the whole token, so there is no parameter to find (RFC 8898).
1438    #[test]
1439    fn an_opaque_scheme_has_its_whole_credential_removed() {
1440        for (scheme, secret) in [
1441            ("Bearer", "BEARERTOKEN0007"),
1442            ("bearer", "BEARERTOKEN0007"),
1443            ("Basic", "BASICSECRET0008"),
1444            // An unregistered scheme whose value carries no `=` can only be a token68.
1445            ("Weird", "WEIRDTOKEN0009"),
1446        ] {
1447            let raw = message(&[
1448                "REGISTER sip:example.net SIP/2.0",
1449                &format!("Authorization: {scheme} {secret}"),
1450            ]);
1451            let out =
1452                text(&redact(&raw).unwrap_or_else(|| panic!("{scheme} was not redacted at all")));
1453            assert!(!out.contains(secret), "{scheme}: {out}");
1454            // The scheme is kept: which scheme failed is the diagnosis.
1455            assert!(out.contains(scheme), "{scheme} should survive: {out}");
1456        }
1457    }
1458
1459    /// A digest challenge still redacts by parameter rather than being read as an opaque token.
1460    #[test]
1461    fn a_digest_header_is_not_mistaken_for_an_opaque_credential() {
1462        let raw = message(&[
1463            "REGISTER sip:example.net SIP/2.0",
1464            "Authorization: Digest realm=\"example.net\", nonce=\"n\", response=\"DIGEST0010\"",
1465        ]);
1466        let out = text(&redact(&raw).expect("redacted"));
1467        assert!(!out.contains("DIGEST0010"), "{out}");
1468        assert!(out.contains("realm=\"example.net\""), "{out}");
1469        assert!(out.contains("nonce=\"n\""), "{out}");
1470    }
1471
1472    /// SDP `k=` carries a key in cleartext (RFC 4566 §5.12). Deprecated, and still a key.
1473    #[test]
1474    fn an_sdp_key_field_is_redacted_but_prompt_is_not() {
1475        let raw = message(&[
1476            "INVITE sip:bob@example.net SIP/2.0",
1477            "",
1478            "v=0",
1479            "k=base64:SDPKEY0011aaaa",
1480        ]);
1481        let redacted = redact(&raw).expect("a k= key is redacted");
1482        let out = text(&redacted);
1483        assert!(!out.contains("SDPKEY0011"), "{out}");
1484        assert!(out.contains("k=base64:"), "the method is kept: {out}");
1485        assert_eq!(redacted.len(), raw.len(), "length preserved");
1486
1487        let prompt = message(&["INVITE sip:bob@example.net SIP/2.0", "", "v=0", "k=prompt"]);
1488        assert!(
1489            redact(&prompt).is_none(),
1490            "k=prompt carries no key, so there is nothing to rewrite"
1491        );
1492    }
1493
1494    /// A SIP message nested in a body (RFC 3420 `message/sipfrag`, or a multipart part) puts real
1495    /// headers where the body scanner sees body lines.
1496    #[test]
1497    fn a_credential_nested_in_a_body_is_redacted() {
1498        let raw = message(&[
1499            "INVITE sip:bob@example.net SIP/2.0",
1500            "Content-Type: message/sipfrag",
1501            "",
1502            "REGISTER sip:inner SIP/2.0",
1503            "Authorization: Digest response=\"NESTEDSECRET0012\"",
1504        ]);
1505        let redacted = redact(&raw).expect("a nested credential is redacted");
1506        let out = text(&redacted);
1507        assert!(!out.contains("NESTEDSECRET0012"), "{out}");
1508        assert_eq!(
1509            redacted.len(),
1510            raw.len(),
1511            "a body stays the length Content-Length claims"
1512        );
1513    }
1514
1515    /// A quoted-pair does not end the quoted string (RFC 3261 §25.1), so the tail after an escaped
1516    /// quote is part of the value rather than something to leave behind.
1517    #[test]
1518    fn an_escaped_quote_inside_a_value_does_not_end_it() {
1519        let raw = message(&[
1520            "REGISTER sip:example.net SIP/2.0",
1521            "Authorization: Digest response=\"aaa\\\"TAIL0013\"",
1522        ]);
1523        let out = text(&redact(&raw).expect("redacted"));
1524        assert!(
1525            !out.contains("TAIL0013"),
1526            "the escaped tail survived: {out}"
1527        );
1528    }
1529
1530    /// A message with nothing to hide is not copied, and is not marked as redacted.
1531    ///
1532    /// `redact`'s own contract, and the thing that keeps redaction off the cost of an ordinary
1533    /// capture: a folded `From` header must come back untouched rather than silently unfolded.
1534    #[test]
1535    fn a_message_with_no_credential_is_never_rewritten() {
1536        let folded = message(&[
1537            "INVITE sip:bob@example.net SIP/2.0",
1538            "From: \"Alice\"",
1539            " <sip:alice@example.net>;tag=abcd",
1540            "Subject: a response= that is not a parameter",
1541        ]);
1542        assert!(
1543            redact(&folded).is_none(),
1544            "nothing to redact must mean no copy, so a fold survives untouched"
1545        );
1546    }
1547
1548    /// **The "costs nothing when off" claim, as a test that fails if the cost comes back.**
1549    ///
1550    /// The Acceptance says capture must cost nothing when off, and a version of this module took
1551    /// `&Bytes` — so every inbound TCP, TLS and WebSocket message was re-serialised and heap-allocated
1552    /// to be handed to a capture that did not exist. That is invisible to a test asserting "no file
1553    /// and zero counters", which is why the guard lives in one function and this asserts the closure
1554    /// is never called.
1555    #[test]
1556    fn no_capture_means_the_bytes_are_never_produced() {
1557        let meters = Meters::default();
1558        let produced = std::cell::Cell::new(false);
1559
1560        Capture::observe_if_capturing(
1561            None,
1562            &meters,
1563            "127.0.0.1:5060".parse().expect("valid"),
1564            "127.0.0.1:5061".parse().expect("valid"),
1565            TransportKind::Tcp,
1566            Direction::In,
1567            || {
1568                produced.set(true);
1569                Bytes::from_static(b"OPTIONS sip:x SIP/2.0\r\n\r\n")
1570            },
1571        );
1572
1573        assert!(
1574            !produced.get(),
1575            "with no capture configured the message must not even be serialised"
1576        );
1577        assert_eq!(
1578            meters.snapshot().capture,
1579            crate::counters::CaptureCounts::default(),
1580            "and nothing is counted"
1581        );
1582    }
1583
1584    #[test]
1585    fn a_header_name_is_matched_without_regard_to_case() {
1586        let out = text(
1587            &redact(b"AUTHORIZATION: Digest RESPONSE=\"secret\"\r\n\r\n")
1588                .expect("header and parameter names are case-insensitive"),
1589        );
1590        assert!(!out.contains("secret"), "{out}");
1591    }
1592
1593    /// The IPv4 header checksum is the one checksum §13.1 keeps. A header that already carries a
1594    /// correct checksum sums to zero, which is the standard way to check one.
1595    #[test]
1596    fn the_synthesised_ipv4_header_carries_a_valid_checksum() {
1597        let record = Record {
1598            seq: 1,
1599            at: UNIX_EPOCH,
1600            local: "192.0.2.1:5060".parse().unwrap(),
1601            peer: "192.0.2.9:5061".parse().unwrap(),
1602            transport: TransportKind::Udp,
1603            direction: Direction::Out,
1604            bytes: Bytes::from_static(b"OPTIONS sip:x SIP/2.0\r\n\r\n"),
1605            redacted: false,
1606        };
1607        let packet = synthesise(&record);
1608        assert_eq!(packet[0], 0x45, "IPv4 with a twenty-byte header");
1609        assert_eq!(packet[9], IP_PROTO_UDP);
1610        assert_eq!(
1611            ones_complement(&packet[..20]),
1612            0,
1613            "a header with a correct checksum sums to zero"
1614        );
1615        // The real ports, in the synthetic UDP header.
1616        assert_eq!(&packet[20..22], &5060u16.to_be_bytes());
1617        assert_eq!(&packet[22..24], &5061u16.to_be_bytes());
1618        assert_eq!(&packet[28..], record.bytes.as_ref());
1619    }
1620
1621    #[test]
1622    fn a_tls_record_says_it_was_decrypted_in_process() {
1623        let record = Record {
1624            seq: 7,
1625            at: UNIX_EPOCH,
1626            local: "192.0.2.1:5061".parse().unwrap(),
1627            peer: "192.0.2.9:5061".parse().unwrap(),
1628            transport: TransportKind::Tls,
1629            direction: Direction::In,
1630            bytes: Bytes::from_static(b"SIP/2.0 200 OK\r\n\r\n"),
1631            redacted: true,
1632        };
1633        let comment = comment(&record);
1634        assert!(comment.contains("seq=7"), "{comment}");
1635        assert!(comment.contains("dir=in"), "{comment}");
1636        assert!(comment.contains("transport=TLS"), "{comment}");
1637        assert!(comment.contains("decrypted-in-process=yes"), "{comment}");
1638        assert!(comment.contains("redacted=yes"), "{comment}");
1639    }
1640
1641    #[test]
1642    fn ipv6_is_synthesised_as_ipv6() {
1643        let record = Record {
1644            seq: 1,
1645            at: UNIX_EPOCH,
1646            local: "[2001:db8::1]:5060".parse().unwrap(),
1647            peer: "[2001:db8::2]:5060".parse().unwrap(),
1648            transport: TransportKind::Udp,
1649            direction: Direction::In,
1650            bytes: Bytes::from_static(b"x"),
1651            redacted: false,
1652        };
1653        let packet = synthesise(&record);
1654        assert_eq!(packet[0] >> 4, 6, "version 6");
1655        assert_eq!(packet[6], IP_PROTO_UDP, "next header");
1656        assert_eq!(packet.len(), 40 + 8 + 1);
1657    }
1658
1659    /// OE-H-1: byte-exact because chunk order, byte order and inclusive chunk lengths are the
1660    /// interoperability contract. Each address and number is recognisable in the literal.
1661    #[test]
1662    fn hep_ipv4_udp_vector_is_byte_exact() {
1663        let record = Record {
1664            seq: 1,
1665            at: UNIX_EPOCH + std::time::Duration::new(1, 2_000),
1666            local: "192.0.2.10:5060".parse().unwrap(),
1667            peer: "198.51.100.20:5080".parse().unwrap(),
1668            transport: TransportKind::Udp,
1669            direction: Direction::Out,
1670            bytes: Bytes::from_static(b"SIP"),
1671            redacted: true,
1672        };
1673        let encoded = encode_hep(&record, 0x0102_0304).expect("encodes");
1674        let expected = [
1675            b'H', b'E', b'P', b'3', 0x00, 0x66, // total length 102
1676            0, 0, 0, 1, 0, 7, 2, // IPv4
1677            0, 0, 0, 2, 0, 7, 17, // UDP
1678            0, 0, 0, 3, 0, 10, 192, 0, 2, 10, // source address
1679            0, 0, 0, 4, 0, 10, 198, 51, 100, 20, // destination address
1680            0, 0, 0, 7, 0, 8, 0x13, 0xc4, // source port 5060
1681            0, 0, 0, 8, 0, 8, 0x13, 0xd8, // destination port 5080
1682            0, 0, 0, 9, 0, 10, 0, 0, 0, 1, // seconds
1683            0, 0, 0, 10, 0, 10, 0, 0, 0, 2, // microseconds
1684            0, 0, 0, 11, 0, 7, 1, // SIP protocol
1685            0, 0, 0, 12, 0, 10, 1, 2, 3, 4, // capture id
1686            0, 0, 0, 15, 0, 9, b'S', b'I', b'P', // payload
1687        ];
1688        assert_eq!(encoded, expected);
1689    }
1690
1691    /// OE-H-3. A collector that could not even open is the deterministic form of an unreachable
1692    /// collector: the record is counted as a HEP drop and remains writable to pcapng.
1693    #[test]
1694    fn an_unavailable_hep_sink_drops_without_disabling_local_capture() {
1695        let record = Record {
1696            seq: 1,
1697            at: UNIX_EPOCH,
1698            local: "192.0.2.10:5060".parse().unwrap(),
1699            peer: "198.51.100.20:5080".parse().unwrap(),
1700            transport: TransportKind::Udp,
1701            direction: Direction::Out,
1702            bytes: Bytes::from_static(b"OPTIONS sip:x SIP/2.0\r\n\r\n"),
1703            redacted: true,
1704        };
1705        let meters = Meters::default();
1706        let mut exporter = HepExporter {
1707            socket: None,
1708            config: HepConfig::new("127.0.0.1:9060".parse().unwrap(), 7),
1709            warned: true,
1710        };
1711        exporter.export(&record, &meters);
1712        let capture = meters.snapshot().capture;
1713        assert_eq!(capture.hep_records, 0);
1714        assert_eq!(capture.hep_dropped, 1);
1715        assert_eq!(capture.errors, 0, "HEP failure does not disable pcapng");
1716
1717        let mut pcapng = Vec::new();
1718        write_packet(&mut pcapng, &record).expect("local capture remains writable");
1719        assert!(!pcapng.is_empty());
1720    }
1721}