Skip to main content

sim_lib_stream_core/
security.rs

1//! Stream capability and security policy model.
2//!
3//! The kernel defines the capability contract (`CapabilityName`) and the
4//! expression graph (`Expr`); this module supplies the concrete streaming-fabric
5//! capabilities a stream may exercise and the policy that bounds remote access
6//! and redacts sensitive payloads. [`StreamSecurityCapability`] names the gated
7//! operations, [`StreamRemoteLimits`] bounds what a remote boundary may carry,
8//! [`StreamSecurityPolicy`] inspects expressions for leaked secrets, and
9//! [`StreamRedactionFinding`] records what a redaction scan caught.
10
11use sim_kernel::{CapabilityName, Error, Expr, Result, Symbol};
12
13use crate::{StreamCapability, TransportProfile};
14
15/// Concrete capability a stream may exercise, gated against the kernel
16/// capability contract.
17///
18/// Each variant names one stream operation or remote surface; the runtime
19/// checks the corresponding [`CapabilityName`] before allowing the action.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum StreamSecurityCapability {
22    /// Open a stream.
23    Open,
24    /// Read frames from a stream.
25    Read,
26    /// Push frames into a stream.
27    Push,
28    /// Cancel an active stream.
29    Cancel,
30    /// Read stream statistics.
31    Stats,
32    /// Preview a stream across a remote boundary.
33    RemotePreview,
34    /// Render a stream across a remote boundary.
35    RemoteRender,
36    /// Access LAN MIDI transport.
37    LanMidi,
38    /// Access a host audio/MIDI device.
39    HostDevice,
40    /// Cross a remote network boundary.
41    RemoteNetwork,
42}
43
44impl StreamSecurityCapability {
45    /// Returns the stable dotted wire label for this capability (for example
46    /// `stream.open`).
47    pub fn wire_label(self) -> &'static str {
48        match self {
49            Self::Open => "stream.open",
50            Self::Read => "stream.read",
51            Self::Push => "stream.push",
52            Self::Cancel => "stream.cancel",
53            Self::Stats => "stream.stats",
54            Self::RemotePreview => "stream.remote.preview",
55            Self::RemoteRender => "stream.remote.render",
56            Self::LanMidi => "stream.lan.midi",
57            Self::HostDevice => "stream.host.device",
58            Self::RemoteNetwork => "stream.remote.network",
59        }
60    }
61
62    /// Returns the kernel [`CapabilityName`] this capability checks against.
63    pub fn capability(self) -> CapabilityName {
64        CapabilityName::new(self.wire_label())
65    }
66
67    /// Returns the qualified symbol naming this capability in the
68    /// `stream/security-capability` namespace.
69    pub fn symbol(self) -> Symbol {
70        Symbol::qualified("stream/security-capability", self.wire_label())
71    }
72}
73
74/// Returns the capability name gating opening a stream.
75pub fn stream_open_capability() -> CapabilityName {
76    StreamSecurityCapability::Open.capability()
77}
78
79/// Returns the capability name gating reading from a stream.
80pub fn stream_read_capability() -> CapabilityName {
81    StreamSecurityCapability::Read.capability()
82}
83
84/// Returns the capability name gating pushing into a stream.
85pub fn stream_push_capability() -> CapabilityName {
86    StreamSecurityCapability::Push.capability()
87}
88
89/// Returns the capability name gating cancelling a stream.
90pub fn stream_cancel_capability() -> CapabilityName {
91    StreamSecurityCapability::Cancel.capability()
92}
93
94/// Returns the capability name gating reading stream statistics.
95pub fn stream_stats_capability() -> CapabilityName {
96    StreamSecurityCapability::Stats.capability()
97}
98
99/// Returns the capability name gating remote stream preview.
100pub fn stream_remote_preview_capability() -> CapabilityName {
101    StreamSecurityCapability::RemotePreview.capability()
102}
103
104/// Returns the capability name gating remote stream render.
105pub fn stream_remote_render_capability() -> CapabilityName {
106    StreamSecurityCapability::RemoteRender.capability()
107}
108
109/// Returns the capability name gating LAN MIDI access.
110pub fn stream_lan_midi_capability() -> CapabilityName {
111    StreamSecurityCapability::LanMidi.capability()
112}
113
114/// Returns the capability name gating host device access.
115pub fn stream_host_device_capability() -> CapabilityName {
116    StreamSecurityCapability::HostDevice.capability()
117}
118
119/// Returns the capability name gating remote network access.
120pub fn stream_remote_network_capability() -> CapabilityName {
121    StreamSecurityCapability::RemoteNetwork.capability()
122}
123
124/// Returns the full set of stream security capabilities in declaration order.
125pub fn stream_security_capabilities() -> [StreamSecurityCapability; 10] {
126    [
127        StreamSecurityCapability::Open,
128        StreamSecurityCapability::Read,
129        StreamSecurityCapability::Push,
130        StreamSecurityCapability::Cancel,
131        StreamSecurityCapability::Stats,
132        StreamSecurityCapability::RemotePreview,
133        StreamSecurityCapability::RemoteRender,
134        StreamSecurityCapability::LanMidi,
135        StreamSecurityCapability::HostDevice,
136        StreamSecurityCapability::RemoteNetwork,
137    ]
138}
139
140/// Returns the kernel capability names for every stream security capability.
141pub fn stream_security_capability_names() -> Vec<CapabilityName> {
142    stream_security_capabilities()
143        .into_iter()
144        .map(StreamSecurityCapability::capability)
145        .collect()
146}
147
148/// Quantitative bounds a stream must respect when it crosses a remote boundary.
149///
150/// These limits cap payload size, frame count, concurrency, lifetime, and rate
151/// so a remote peer cannot exhaust local resources.
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub struct StreamRemoteLimits {
154    /// Maximum bytes carried in a single frame payload.
155    pub max_frame_payload_bytes: usize,
156    /// Maximum number of frames a stream may carry.
157    pub max_stream_frames: usize,
158    /// Maximum number of frames in flight at once.
159    pub max_inflight_frames: usize,
160    /// Maximum stream lifetime in milliseconds.
161    pub max_duration_ms: u64,
162    /// Maximum frame rate in hertz.
163    pub max_rate_hz: u32,
164    /// Maximum bytes carried in a single binary payload.
165    pub max_binary_payload_bytes: usize,
166}
167
168impl Default for StreamRemoteLimits {
169    fn default() -> Self {
170        Self {
171            max_frame_payload_bytes: 1024 * 1024,
172            max_stream_frames: 1024,
173            max_inflight_frames: 64,
174            max_duration_ms: 60_000,
175            max_rate_hz: 120,
176            max_binary_payload_bytes: 256 * 1024,
177        }
178    }
179}
180
181impl StreamRemoteLimits {
182    /// Checks that every positive-only limit is non-zero.
183    ///
184    /// Returns an [`Error::Eval`] naming the first limit that is zero.
185    pub fn validate(self) -> Result<()> {
186        if self.max_frame_payload_bytes == 0 {
187            return Err(Error::Eval(
188                "stream remote frame-size limit must be positive".to_owned(),
189            ));
190        }
191        if self.max_duration_ms == 0 {
192            return Err(Error::Eval(
193                "stream remote duration limit must be positive".to_owned(),
194            ));
195        }
196        if self.max_rate_hz == 0 {
197            return Err(Error::Eval(
198                "stream remote rate limit must be positive".to_owned(),
199            ));
200        }
201        if self.max_binary_payload_bytes == 0 {
202            return Err(Error::Eval(
203                "stream remote binary payload limit must be positive".to_owned(),
204            ));
205        }
206        Ok(())
207    }
208
209    /// Validates these limits against a transport profile.
210    ///
211    /// Beyond [`validate`](Self::validate), this rejects a realtime profile that
212    /// lacks local preview transport and a remote profile that crosses a
213    /// boundary without bounded limits.
214    pub fn validate_profile(self, profile: &TransportProfile) -> Result<()> {
215        self.validate()?;
216        if profile.has_capability(StreamCapability::Realtime)
217            && !profile.has_capability(StreamCapability::Preview)
218        {
219            return Err(Error::Eval(format!(
220                "stream profile {} requires local realtime transport",
221                profile.name()
222            )));
223        }
224        if profile.has_capability(StreamCapability::Remote)
225            && !profile.has_capability(StreamCapability::Bounded)
226        {
227            return Err(Error::Eval(format!(
228                "stream profile {} crosses a remote boundary without bounded limits",
229                profile.name()
230            )));
231        }
232        Ok(())
233    }
234
235    /// Returns the effective frame ceiling: the smaller of the configured frame
236    /// cap and the frames implied by the duration and rate limits.
237    pub fn effective_frame_limit(self) -> usize {
238        let rate_duration = (self.max_duration_ms as u128)
239            .saturating_mul(self.max_rate_hz as u128)
240            .div_ceil(1000);
241        self.max_stream_frames
242            .min(rate_duration.max(1).min(usize::MAX as u128) as usize)
243    }
244
245    /// Encodes these limits as a map expression keyed by limit name.
246    pub fn to_expr(self) -> Expr {
247        Expr::Map(vec![
248            field(
249                "max-frame-payload-bytes",
250                self.max_frame_payload_bytes.to_string(),
251            ),
252            field("max-stream-frames", self.max_stream_frames.to_string()),
253            field("max-inflight-frames", self.max_inflight_frames.to_string()),
254            field("max-duration-ms", self.max_duration_ms.to_string()),
255            field("max-rate-hz", self.max_rate_hz.to_string()),
256            field(
257                "max-binary-payload-bytes",
258                self.max_binary_payload_bytes.to_string(),
259            ),
260        ])
261    }
262}
263
264/// Category of sensitive content a redaction scan can flag in a payload.
265#[derive(Clone, Copy, Debug, PartialEq, Eq)]
266pub enum StreamRedactionFinding {
267    /// A private user path (for example a home directory).
268    PrivatePath,
269    /// A host name or URL.
270    HostName,
271    /// An absolute filesystem path.
272    AbsolutePath,
273    /// A credential, token, or secret.
274    Credential,
275    /// A patch-bank or sysex-bank payload.
276    PatchBankPayload,
277    /// A binary payload larger than the configured limit.
278    LargeBinaryData,
279}
280
281impl StreamRedactionFinding {
282    /// Returns the stable wire label for this finding category.
283    pub fn wire_label(self) -> &'static str {
284        match self {
285            Self::PrivatePath => "private-path",
286            Self::HostName => "host-name",
287            Self::AbsolutePath => "absolute-path",
288            Self::Credential => "credential",
289            Self::PatchBankPayload => "patch-bank-payload",
290            Self::LargeBinaryData => "large-binary-data",
291        }
292    }
293
294    /// Returns the qualified symbol naming this finding in the
295    /// `stream/redaction` namespace.
296    pub fn symbol(self) -> Symbol {
297        Symbol::qualified("stream/redaction", self.wire_label())
298    }
299}
300
301/// Returns the qualified symbols for every redaction finding category.
302pub fn stream_redaction_finding_symbols() -> [Symbol; 6] {
303    [
304        StreamRedactionFinding::PrivatePath.symbol(),
305        StreamRedactionFinding::HostName.symbol(),
306        StreamRedactionFinding::AbsolutePath.symbol(),
307        StreamRedactionFinding::Credential.symbol(),
308        StreamRedactionFinding::PatchBankPayload.symbol(),
309        StreamRedactionFinding::LargeBinaryData.symbol(),
310    ]
311}
312
313/// Security policy applied to stream payloads that leave the local boundary.
314///
315/// Combines the [`StreamRemoteLimits`] bounds with a redaction scan over
316/// expression graphs so private paths, host names, credentials, and oversized
317/// binaries do not escape in public payloads.
318#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
319pub struct StreamSecurityPolicy {
320    /// Bounds applied to remote stream access.
321    pub remote_limits: StreamRemoteLimits,
322}
323
324impl StreamSecurityPolicy {
325    /// Rejects an expression destined for a public surface if it contains any
326    /// redaction finding.
327    ///
328    /// Returns an [`Error::Eval`] naming the offending finding category.
329    pub fn validate_public_expr(self, expr: &Expr) -> Result<()> {
330        if let Some(finding) = self.finding_for_expr(expr) {
331            return Err(Error::Eval(format!(
332                "stream public payload contains {}",
333                finding.wire_label()
334            )));
335        }
336        Ok(())
337    }
338
339    /// Recursively scans an expression graph and returns the first redaction
340    /// finding, or `None` if the expression is clean.
341    pub fn finding_for_expr(self, expr: &Expr) -> Option<StreamRedactionFinding> {
342        match expr {
343            Expr::Symbol(symbol) | Expr::Local(symbol) => {
344                self.finding_for_text(&symbol.as_qualified_str())
345            }
346            Expr::String(value) => self.finding_for_text(value),
347            Expr::Bytes(bytes) if bytes.len() > self.remote_limits.max_binary_payload_bytes => {
348                Some(StreamRedactionFinding::LargeBinaryData)
349            }
350            Expr::List(items) | Expr::Vector(items) | Expr::Set(items) | Expr::Block(items) => {
351                items.iter().find_map(|item| self.finding_for_expr(item))
352            }
353            Expr::Map(entries) => entries.iter().find_map(|(key, value)| {
354                self.finding_for_expr(key)
355                    .or_else(|| self.finding_for_expr(value))
356            }),
357            Expr::Call { operator, args } => self
358                .finding_for_expr(operator)
359                .or_else(|| args.iter().find_map(|arg| self.finding_for_expr(arg))),
360            Expr::Infix {
361                operator,
362                left,
363                right,
364            } => self
365                .finding_for_text(&operator.as_qualified_str())
366                .or_else(|| self.finding_for_expr(left))
367                .or_else(|| self.finding_for_expr(right)),
368            Expr::Prefix { operator, arg } | Expr::Postfix { operator, arg } => self
369                .finding_for_text(&operator.as_qualified_str())
370                .or_else(|| self.finding_for_expr(arg)),
371            Expr::Quote { expr, .. } => self.finding_for_expr(expr),
372            Expr::Annotated { expr, annotations } => self.finding_for_expr(expr).or_else(|| {
373                annotations.iter().find_map(|(key, value)| {
374                    self.finding_for_text(&key.as_qualified_str())
375                        .or_else(|| self.finding_for_expr(value))
376                })
377            }),
378            Expr::Extension { tag, payload } => self
379                .finding_for_text(&tag.as_qualified_str())
380                .or_else(|| self.finding_for_expr(payload)),
381            _ => None,
382        }
383    }
384
385    /// Scans a single text value and returns the first redaction finding, or
386    /// `None` if no sensitive pattern matches.
387    pub fn finding_for_text(self, value: &str) -> Option<StreamRedactionFinding> {
388        let lower = value.to_ascii_lowercase();
389        if contains_credential(&lower) {
390            return Some(StreamRedactionFinding::Credential);
391        }
392        if contains_patch_bank(&lower) {
393            return Some(StreamRedactionFinding::PatchBankPayload);
394        }
395        if contains_host_name(value, &lower) {
396            return Some(StreamRedactionFinding::HostName);
397        }
398        if contains_private_path(value, &lower) {
399            return Some(StreamRedactionFinding::PrivatePath);
400        }
401        if contains_absolute_path(value) {
402            return Some(StreamRedactionFinding::AbsolutePath);
403        }
404        None
405    }
406}
407
408fn contains_credential(lower: &str) -> bool {
409    lower.contains("api_key")
410        || lower.contains("apikey")
411        || lower.contains("auth-token")
412        || lower.contains("bearer ")
413        || lower.contains("credential")
414        || lower.contains("password")
415        || lower.contains("secret")
416        || lower.contains("token=")
417}
418
419fn contains_patch_bank(lower: &str) -> bool {
420    lower.contains("patch-bank")
421        || lower.contains("patch_bank")
422        || lower.contains("sysex-bank")
423        || lower.contains("sysex_bank")
424}
425
426fn contains_host_name(_value: &str, lower: &str) -> bool {
427    lower.contains("hostname=")
428        || lower.contains("host=")
429        || lower.contains("http://")
430        || lower.contains("https://")
431        || lower.contains("ws://")
432        || lower.contains("wss://")
433        || lower.contains(".local")
434        || lower.contains(".lan")
435}
436
437fn contains_private_path(value: &str, lower: &str) -> bool {
438    lower.contains("/home/")
439        || lower.contains("/users/")
440        || lower.contains("\\users\\")
441        || lower.contains("/private/")
442        || lower.contains("private/")
443        || lower.contains("private-path")
444        || value.starts_with('~')
445}
446
447fn contains_absolute_path(value: &str) -> bool {
448    value.starts_with('/') || looks_like_windows_absolute_path(value)
449}
450
451fn looks_like_windows_absolute_path(value: &str) -> bool {
452    let bytes = value.as_bytes();
453    bytes.len() > 2
454        && bytes[0].is_ascii_alphabetic()
455        && bytes[1] == b':'
456        && (bytes[2] == b'\\' || bytes[2] == b'/')
457}
458
459fn field(name: &str, value: String) -> (Expr, Expr) {
460    (Expr::Symbol(Symbol::new(name)), Expr::String(value))
461}