Skip to main content

aequora_security/
lib.rs

1//! Runtime-, transport-, provider-, and database-neutral security contracts.
2//!
3//! The crate turns Part 27's threat model into typed, fail-closed boundaries. Authentication
4//! adapters establish [`AuthenticationEvidence`]; authoritative services consume only a
5//! [`ValidatedAuthContext`]. External inputs pass explicit budgets, outbound destinations are
6//! checked after DNS resolution, durable operation identities bind immutable semantics, and
7//! security telemetry remains payload-free and low-cardinality.
8
9#![forbid(unsafe_code)]
10#![allow(clippy::missing_errors_doc)]
11
12use aequora_types::{ActorId, AuthorityEpoch, DeviceId, OperationId, TenantId};
13use serde::{Deserialize, Serialize};
14use std::{
15    collections::{BTreeMap, BTreeSet},
16    fmt,
17    net::{IpAddr, Ipv4Addr},
18};
19use thiserror::Error;
20use uuid::Uuid;
21use zeroize::Zeroizing;
22
23/// Schema version for serialized security policy and event contracts.
24pub const SECURITY_SCHEMA_VERSION: u16 = 1;
25/// Maximum length of issuer, audience, host, and stable reason identifiers.
26pub const MAX_SECURITY_IDENTIFIER_BYTES: usize = 256;
27
28macro_rules! uuid_id {
29    ($name:ident, $doc:literal) => {
30        #[doc = $doc]
31        #[derive(
32            Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
33        )]
34        #[serde(transparent)]
35        pub struct $name(Uuid);
36
37        impl $name {
38            #[must_use]
39            pub fn new() -> Self {
40                Self(Uuid::now_v7())
41            }
42
43            #[must_use]
44            pub const fn from_uuid(value: Uuid) -> Self {
45                Self(value)
46            }
47
48            #[must_use]
49            pub const fn as_uuid(self) -> Uuid {
50                self.0
51            }
52        }
53
54        impl Default for $name {
55            fn default() -> Self {
56                Self::new()
57            }
58        }
59    };
60}
61
62uuid_id!(SecurityEventId, "Stable identity of one security event.");
63
64/// Deployment security posture. These are architecture profiles, not certifications.
65#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
66pub enum SecurityLevel {
67    Standard,
68    Enterprise,
69    HighAssurance,
70}
71
72/// Server-observed authentication mechanism.
73#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
74pub enum AuthMethod {
75    BearerToken,
76    CookieSession,
77    MutualTls,
78    WorkloadIdentity,
79    DeviceSignature,
80}
81
82/// Authentication strength used for step-up decisions.
83#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
84pub enum AssuranceLevel {
85    Normal,
86    MultiFactor,
87    HardwareBacked,
88    BreakGlass,
89}
90
91/// Current server-side device state.
92#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
93pub enum DeviceState {
94    Active,
95    VerificationOnly,
96    Revoked,
97}
98
99/// Authentication evidence produced by a trusted authentication adapter.
100#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
101pub struct AuthenticationEvidence {
102    pub principal_id: ActorId,
103    pub tenant_id: TenantId,
104    pub device_id: Option<DeviceId>,
105    pub auth_method: AuthMethod,
106    pub assurance: AssuranceLevel,
107    pub issuer: String,
108    pub audience: String,
109    pub authenticated_at_unix_ms: u64,
110    pub expires_at_unix_ms: u64,
111    pub device_state: Option<DeviceState>,
112}
113
114/// Authentication policy enforced before protected traffic enters domain authorization.
115#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
116pub struct AuthenticationPolicy {
117    pub trusted_issuers: BTreeSet<String>,
118    pub required_audience: String,
119    pub require_device_binding: bool,
120    pub minimum_assurance: AssuranceLevel,
121    pub max_authentication_age_ms: u64,
122}
123
124impl AuthenticationPolicy {
125    pub fn validate(&self) -> Result<(), SecurityError> {
126        if self.trusted_issuers.is_empty()
127            || self.required_audience.is_empty()
128            || self.max_authentication_age_ms == 0
129            || self.required_audience.len() > MAX_SECURITY_IDENTIFIER_BYTES
130            || self
131                .trusted_issuers
132                .iter()
133                .any(|issuer| issuer.is_empty() || issuer.len() > MAX_SECURITY_IDENTIFIER_BYTES)
134        {
135            return Err(SecurityError::InvalidPolicy("authentication"));
136        }
137        Ok(())
138    }
139
140    pub fn authenticate(
141        &self,
142        evidence: AuthenticationEvidence,
143        now_unix_ms: u64,
144    ) -> Result<ValidatedAuthContext, SecurityError> {
145        self.validate()?;
146        if !self.trusted_issuers.contains(&evidence.issuer)
147            || evidence.audience != self.required_audience
148        {
149            return Err(SecurityError::AuthenticationInvalid);
150        }
151        if now_unix_ms >= evidence.expires_at_unix_ms {
152            return Err(SecurityError::AuthenticationExpired);
153        }
154        let age = now_unix_ms
155            .checked_sub(evidence.authenticated_at_unix_ms)
156            .ok_or(SecurityError::AuthenticationInvalid)?;
157        if age > self.max_authentication_age_ms || evidence.assurance < self.minimum_assurance {
158            return Err(SecurityError::InsufficientAssurance);
159        }
160        if self.require_device_binding && evidence.device_id.is_none() {
161            return Err(SecurityError::DeviceBindingRequired);
162        }
163        if matches!(evidence.device_state, Some(DeviceState::Revoked)) {
164            return Err(SecurityError::DeviceRevoked);
165        }
166        if evidence.device_id.is_some() && evidence.device_state.is_none() {
167            return Err(SecurityError::AuthenticationInvalid);
168        }
169        Ok(ValidatedAuthContext(evidence))
170    }
171}
172
173/// Validated identity. Fields remain private so callers cannot construct trusted context directly.
174#[derive(Clone, Debug, Eq, PartialEq)]
175pub struct ValidatedAuthContext(AuthenticationEvidence);
176
177impl ValidatedAuthContext {
178    #[must_use]
179    pub const fn principal_id(&self) -> ActorId {
180        self.0.principal_id
181    }
182
183    #[must_use]
184    pub const fn tenant_id(&self) -> TenantId {
185        self.0.tenant_id
186    }
187
188    #[must_use]
189    pub const fn device_id(&self) -> Option<DeviceId> {
190        self.0.device_id
191    }
192
193    #[must_use]
194    pub const fn auth_method(&self) -> AuthMethod {
195        self.0.auth_method
196    }
197
198    #[must_use]
199    pub const fn assurance(&self) -> AssuranceLevel {
200        self.0.assurance
201    }
202
203    pub fn bind_tenant(&self, claimed_tenant: TenantId) -> Result<TenantBinding, SecurityError> {
204        if claimed_tenant != self.0.tenant_id {
205            return Err(SecurityError::TenantMismatch);
206        }
207        Ok(TenantBinding {
208            tenant_id: self.0.tenant_id,
209            principal_id: self.0.principal_id,
210        })
211    }
212}
213
214/// Proof that a client claim was checked against server-derived identity.
215#[derive(Clone, Copy, Debug, Eq, PartialEq)]
216pub struct TenantBinding {
217    tenant_id: TenantId,
218    principal_id: ActorId,
219}
220
221impl TenantBinding {
222    #[must_use]
223    pub const fn tenant_id(self) -> TenantId {
224        self.tenant_id
225    }
226
227    #[must_use]
228    pub const fn principal_id(self) -> ActorId {
229        self.principal_id
230    }
231
232    pub fn authorize_resource(
233        self,
234        resource: TenantResource,
235    ) -> Result<AuthorizedResource, SecurityError> {
236        if resource.tenant_id != self.tenant_id {
237            return Err(SecurityError::NotFoundOrForbidden);
238        }
239        Ok(AuthorizedResource(resource))
240    }
241}
242
243/// Identifier category used without exposing the underlying business identifier.
244#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
245pub enum ResourceKind {
246    Entity,
247    Scope,
248    Blob,
249    Operation,
250    Snapshot,
251    Export,
252}
253
254/// Tenant ownership resolved from a trusted repository lookup.
255#[derive(Clone, Copy, Debug, Eq, PartialEq)]
256pub struct TenantResource {
257    pub tenant_id: TenantId,
258    pub kind: ResourceKind,
259}
260
261/// Resource whose trusted ownership matched the authenticated tenant.
262#[derive(Clone, Copy, Debug, Eq, PartialEq)]
263pub struct AuthorizedResource(TenantResource);
264
265impl AuthorizedResource {
266    #[must_use]
267    pub const fn kind(self) -> ResourceKind {
268        self.0.kind
269    }
270}
271
272/// Bounds applied before or during parsing externally controlled input.
273#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
274pub struct ProtocolLimits {
275    pub max_frame_bytes: usize,
276    pub max_operations_per_batch: usize,
277    pub max_collection_items: usize,
278    pub max_nesting_depth: usize,
279    pub max_dependency_edges: usize,
280    pub max_string_bytes: usize,
281    pub max_compressed_bytes: usize,
282    pub max_decompressed_bytes: usize,
283    pub max_compression_ratio: usize,
284}
285
286impl ProtocolLimits {
287    pub fn validate(self) -> Result<(), SecurityError> {
288        let values = [
289            self.max_frame_bytes,
290            self.max_operations_per_batch,
291            self.max_collection_items,
292            self.max_nesting_depth,
293            self.max_dependency_edges,
294            self.max_string_bytes,
295            self.max_compressed_bytes,
296            self.max_decompressed_bytes,
297            self.max_compression_ratio,
298        ];
299        if values.contains(&0)
300            || self.max_compressed_bytes > self.max_decompressed_bytes
301            || self.max_frame_bytes > self.max_decompressed_bytes
302        {
303            return Err(SecurityError::InvalidPolicy("protocol limits"));
304        }
305        Ok(())
306    }
307
308    pub fn validate_input(self, input: InputShape) -> Result<(), SecurityError> {
309        self.validate()?;
310        if input.frame_bytes > self.max_frame_bytes
311            || input.collection_items > self.max_collection_items
312            || input.operations > self.max_operations_per_batch
313            || input.nesting_depth > self.max_nesting_depth
314            || input.dependency_edges > self.max_dependency_edges
315            || input.longest_string_bytes > self.max_string_bytes
316        {
317            return Err(SecurityError::InputLimitExceeded);
318        }
319        Ok(())
320    }
321
322    pub fn validate_compression(
323        self,
324        compressed_bytes: usize,
325        decompressed_bytes: usize,
326    ) -> Result<(), SecurityError> {
327        self.validate()?;
328        if compressed_bytes > self.max_compressed_bytes
329            || decompressed_bytes > self.max_decompressed_bytes
330        {
331            return Err(SecurityError::CompressionLimitExceeded);
332        }
333        let permitted = compressed_bytes
334            .max(1)
335            .checked_mul(self.max_compression_ratio)
336            .ok_or(SecurityError::ArithmeticOverflow)?;
337        if decompressed_bytes > permitted {
338            return Err(SecurityError::CompressionLimitExceeded);
339        }
340        Ok(())
341    }
342}
343
344/// Measured shape supplied by a bounded decoder.
345#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
346pub struct InputShape {
347    pub frame_bytes: usize,
348    pub collection_items: usize,
349    pub operations: usize,
350    pub nesting_depth: usize,
351    pub dependency_edges: usize,
352    pub longest_string_bytes: usize,
353}
354
355/// Bounds for uploaded blobs and archive expansion.
356#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
357pub struct UploadLimits {
358    pub max_blob_bytes: u64,
359    pub max_chunks: usize,
360    pub max_archive_entries: usize,
361    pub max_archive_expanded_bytes: u64,
362    pub max_archive_ratio: u64,
363    pub max_path_bytes: usize,
364}
365
366impl UploadLimits {
367    pub fn validate(self) -> Result<(), SecurityError> {
368        if self.max_blob_bytes == 0
369            || self.max_chunks == 0
370            || self.max_archive_entries == 0
371            || self.max_archive_expanded_bytes == 0
372            || self.max_archive_ratio == 0
373            || self.max_path_bytes == 0
374        {
375            return Err(SecurityError::InvalidPolicy("upload limits"));
376        }
377        Ok(())
378    }
379
380    pub fn validate_archive(self, entries: &[ArchiveEntry]) -> Result<(), SecurityError> {
381        self.validate()?;
382        if entries.len() > self.max_archive_entries {
383            return Err(SecurityError::ArchiveLimitExceeded);
384        }
385        let mut compressed = 0_u64;
386        let mut expanded = 0_u64;
387        for entry in entries {
388            entry.validate_path(self.max_path_bytes)?;
389            if entry.kind != ArchiveEntryKind::RegularFile {
390                return Err(SecurityError::UnsafeArchiveEntry);
391            }
392            compressed = compressed
393                .checked_add(entry.compressed_bytes)
394                .ok_or(SecurityError::ArithmeticOverflow)?;
395            expanded = expanded
396                .checked_add(entry.expanded_bytes)
397                .ok_or(SecurityError::ArithmeticOverflow)?;
398        }
399        let allowed_expansion = compressed
400            .max(1)
401            .checked_mul(self.max_archive_ratio)
402            .ok_or(SecurityError::ArithmeticOverflow)?;
403        if expanded > self.max_archive_expanded_bytes || expanded > allowed_expansion {
404            return Err(SecurityError::ArchiveLimitExceeded);
405        }
406        Ok(())
407    }
408}
409
410/// Archive entry metadata validated before extraction.
411#[derive(Clone, Debug, Eq, PartialEq)]
412pub struct ArchiveEntry {
413    pub relative_path: String,
414    pub kind: ArchiveEntryKind,
415    pub compressed_bytes: u64,
416    pub expanded_bytes: u64,
417}
418
419impl ArchiveEntry {
420    fn validate_path(&self, max_path_bytes: usize) -> Result<(), SecurityError> {
421        let path = self.relative_path.as_str();
422        if path.is_empty()
423            || path.len() > max_path_bytes
424            || path.starts_with('/')
425            || path.starts_with('\\')
426            || path.contains('\\')
427            || path.contains('\0')
428            || path
429                .split('/')
430                .any(|part| part.is_empty() || matches!(part, "." | ".."))
431            || path.as_bytes().get(1) == Some(&b':')
432        {
433            return Err(SecurityError::UnsafeArchivePath);
434        }
435        Ok(())
436    }
437}
438
439#[derive(Clone, Copy, Debug, Eq, PartialEq)]
440pub enum ArchiveEntryKind {
441    RegularFile,
442    Directory,
443    SymbolicLink,
444    HardLink,
445    Device,
446}
447
448/// Egress policy applied to every initial URL and every redirect.
449#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
450pub struct EgressPolicy {
451    pub allow_plain_http: bool,
452    pub block_private_networks: bool,
453    pub max_redirects: usize,
454    pub max_url_bytes: usize,
455}
456
457impl EgressPolicy {
458    pub fn validate(self) -> Result<(), SecurityError> {
459        if self.max_url_bytes == 0 || self.max_url_bytes > 16 * 1024 {
460            return Err(SecurityError::InvalidPolicy("egress"));
461        }
462        Ok(())
463    }
464
465    pub fn validate_target(
466        self,
467        url: &str,
468        resolved_addresses: &[IpAddr],
469        redirect_count: usize,
470    ) -> Result<ValidatedOutboundTarget, SecurityError> {
471        self.validate()?;
472        if url.len() > self.max_url_bytes || redirect_count > self.max_redirects {
473            return Err(SecurityError::SsrfBlocked);
474        }
475        let parsed = ParsedTarget::parse(url, self.allow_plain_http)?;
476        parsed.validate_addresses(resolved_addresses, self.block_private_networks)?;
477        Ok(ValidatedOutboundTarget {
478            scheme: parsed.scheme,
479            host: parsed.host,
480            port: parsed.port,
481            addresses: resolved_addresses.to_vec(),
482            redirect_count,
483        })
484    }
485}
486
487struct ParsedTarget {
488    scheme: OutboundScheme,
489    host: String,
490    port: Option<u16>,
491}
492
493impl ParsedTarget {
494    fn parse(url: &str, allow_plain_http: bool) -> Result<Self, SecurityError> {
495        let (raw_scheme, rest) = url.split_once("://").ok_or(SecurityError::SsrfBlocked)?;
496        let scheme = match raw_scheme.to_ascii_lowercase().as_str() {
497            "https" => OutboundScheme::Https,
498            "http" if allow_plain_http => OutboundScheme::Http,
499            _ => return Err(SecurityError::SsrfBlocked),
500        };
501        let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
502        if authority.is_empty() || authority.contains('@') {
503            return Err(SecurityError::SsrfBlocked);
504        }
505        let (host, port) = parse_authority(authority)?;
506        let normalized = host.trim_end_matches('.').to_ascii_lowercase();
507        if normalized.is_empty()
508            || normalized == "localhost"
509            || normalized.ends_with(".localhost")
510            || normalized == "metadata.google.internal"
511        {
512            return Err(SecurityError::SsrfBlocked);
513        }
514        Ok(Self {
515            scheme,
516            host: normalized,
517            port,
518        })
519    }
520
521    fn validate_addresses(
522        &self,
523        addresses: &[IpAddr],
524        block_private: bool,
525    ) -> Result<(), SecurityError> {
526        if addresses.is_empty()
527            || addresses
528                .iter()
529                .any(|address| block_private && !is_public_address(*address))
530        {
531            return Err(SecurityError::SsrfBlocked);
532        }
533        if let Ok(literal) = self.host.parse::<IpAddr>() {
534            if !addresses.contains(&literal) {
535                return Err(SecurityError::SsrfBlocked);
536            }
537        }
538        Ok(())
539    }
540}
541
542fn parse_authority(authority: &str) -> Result<(&str, Option<u16>), SecurityError> {
543    if let Some(bracketed) = authority.strip_prefix('[') {
544        let (host, suffix) = bracketed
545            .split_once(']')
546            .ok_or(SecurityError::SsrfBlocked)?;
547        let port = match suffix.strip_prefix(':') {
548            Some(value) if !value.is_empty() => {
549                Some(value.parse().map_err(|_| SecurityError::SsrfBlocked)?)
550            }
551            None if suffix.is_empty() => None,
552            _ => return Err(SecurityError::SsrfBlocked),
553        };
554        host.parse::<std::net::Ipv6Addr>()
555            .map_err(|_| SecurityError::SsrfBlocked)?;
556        return Ok((host, port));
557    }
558    if authority.matches(':').count() > 1 {
559        return Err(SecurityError::SsrfBlocked);
560    }
561    match authority.rsplit_once(':') {
562        Some((host, port)) if !host.is_empty() && !port.is_empty() => Ok((
563            host,
564            Some(port.parse().map_err(|_| SecurityError::SsrfBlocked)?),
565        )),
566        Some(_) => Err(SecurityError::SsrfBlocked),
567        None => Ok((authority, None)),
568    }
569}
570
571fn is_public_address(address: IpAddr) -> bool {
572    if address.is_loopback() || address.is_unspecified() || address.is_multicast() {
573        return false;
574    }
575    match address {
576        IpAddr::V4(value) => {
577            !value.is_private()
578                && !value.is_link_local()
579                && !value.is_broadcast()
580                && !value.is_documentation()
581                && value != Ipv4Addr::new(169, 254, 169, 254)
582                && value.octets()[0] != 0
583                && !(value.octets()[0] == 100 && (64..=127).contains(&value.octets()[1]))
584                && !(value.octets()[0] == 198 && matches!(value.octets()[1], 18 | 19))
585                && value.octets()[0] < 240
586        }
587        IpAddr::V6(value) => {
588            if let Some(mapped) = value.to_ipv4_mapped() {
589                return is_public_address(IpAddr::V4(mapped));
590            }
591            !(value.is_unique_local()
592                || value.is_unicast_link_local()
593                || value.segments()[0] & 0xffc0 == 0xfec0
594                || (value.segments()[0] == 0x2001 && value.segments()[1] == 0x0db8))
595        }
596    }
597}
598
599#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
600pub enum OutboundScheme {
601    Http,
602    Https,
603}
604
605/// Destination checked against DNS answers. Revalidate immediately before every connection.
606#[derive(Clone, Debug, Eq, PartialEq)]
607pub struct ValidatedOutboundTarget {
608    pub scheme: OutboundScheme,
609    pub host: String,
610    pub port: Option<u16>,
611    pub addresses: Vec<IpAddr>,
612    pub redirect_count: usize,
613}
614
615impl ValidatedOutboundTarget {
616    pub fn revalidate_connection(
617        &self,
618        policy: EgressPolicy,
619        connected_address: IpAddr,
620    ) -> Result<(), SecurityError> {
621        if !self.addresses.contains(&connected_address)
622            || (policy.block_private_networks && !is_public_address(connected_address))
623        {
624            return Err(SecurityError::SsrfBlocked);
625        }
626        Ok(())
627    }
628}
629
630/// Immutable semantic binding for one operation identity.
631#[derive(Clone, Copy, Debug, Eq, PartialEq)]
632pub struct OperationBinding {
633    pub operation_id: OperationId,
634    pub tenant_id: TenantId,
635    pub digest: [u8; 32],
636}
637
638impl OperationBinding {
639    #[must_use]
640    pub fn new(
641        operation_id: OperationId,
642        tenant_id: TenantId,
643        actor_id: ActorId,
644        device_id: Option<DeviceId>,
645        semantic_payload: &[u8],
646    ) -> Self {
647        let mut hasher = blake3::Hasher::new();
648        hasher.update(b"aequora.security.operation-binding.v1\0");
649        hasher.update(tenant_id.as_uuid().as_bytes());
650        hasher.update(actor_id.as_uuid().as_bytes());
651        match device_id {
652            Some(device_id) => {
653                hasher.update(&[1]);
654                hasher.update(device_id.as_uuid().as_bytes());
655            }
656            None => {
657                hasher.update(&[0]);
658            }
659        }
660        hasher.update(&(semantic_payload.len() as u64).to_le_bytes());
661        hasher.update(semantic_payload);
662        Self {
663            operation_id,
664            tenant_id,
665            digest: *hasher.finalize().as_bytes(),
666        }
667    }
668}
669
670/// Bounded reference replay registry. Durable ledgers implement the same comparison contract.
671#[derive(Debug)]
672pub struct OperationReplayGuard {
673    max_entries: usize,
674    bindings: BTreeMap<OperationId, OperationBinding>,
675}
676
677impl OperationReplayGuard {
678    pub fn new(max_entries: usize) -> Result<Self, SecurityError> {
679        if max_entries == 0 {
680            return Err(SecurityError::InvalidPolicy("replay registry"));
681        }
682        Ok(Self {
683            max_entries,
684            bindings: BTreeMap::new(),
685        })
686    }
687
688    pub fn observe(
689        &mut self,
690        binding: OperationBinding,
691    ) -> Result<ReplayDisposition, SecurityError> {
692        if let Some(existing) = self.bindings.get(&binding.operation_id) {
693            return if *existing == binding {
694                Ok(ReplayDisposition::Duplicate)
695            } else {
696                Err(SecurityError::PayloadMismatch)
697            };
698        }
699        if self.bindings.len() >= self.max_entries {
700            return Err(SecurityError::InputLimitExceeded);
701        }
702        self.bindings.insert(binding.operation_id, binding);
703        Ok(ReplayDisposition::FirstSeen)
704    }
705}
706
707#[derive(Clone, Copy, Debug, Eq, PartialEq)]
708pub enum ReplayDisposition {
709    FirstSeen,
710    Duplicate,
711}
712
713/// Authority rollback guard retained by clients and trusted stores.
714#[derive(Clone, Copy, Debug, Eq, PartialEq)]
715pub struct AuthorityEpochGuard {
716    highest_trusted: AuthorityEpoch,
717}
718
719impl AuthorityEpochGuard {
720    #[must_use]
721    pub const fn new(highest_trusted: AuthorityEpoch) -> Self {
722        Self { highest_trusted }
723    }
724
725    pub fn observe(&mut self, observed: AuthorityEpoch) -> Result<EpochDisposition, SecurityError> {
726        if observed < self.highest_trusted {
727            return Err(SecurityError::AuthorityRollback);
728        }
729        if observed > self.highest_trusted {
730            self.highest_trusted = observed;
731            return Ok(EpochDisposition::Advanced);
732        }
733        Ok(EpochDisposition::Current)
734    }
735}
736
737#[derive(Clone, Copy, Debug, Eq, PartialEq)]
738pub enum EpochDisposition {
739    Current,
740    Advanced,
741}
742
743/// Side-effect risk category used to enforce explicit idempotency/reconciliation.
744#[derive(Clone, Copy, Debug, Eq, PartialEq)]
745pub enum SideEffectRisk {
746    Reversible,
747    Financial,
748    Irreversible,
749}
750
751#[derive(Clone, Debug, Eq, PartialEq)]
752pub struct SideEffectSafety {
753    pub risk: SideEffectRisk,
754    pub idempotency_key: Option<String>,
755    pub reconciliation_kind: Option<String>,
756}
757
758impl SideEffectSafety {
759    pub fn validate(&self) -> Result<(), SecurityError> {
760        if matches!(
761            self.risk,
762            SideEffectRisk::Financial | SideEffectRisk::Irreversible
763        ) && (self.idempotency_key.as_deref().is_none_or(str::is_empty)
764            || self
765                .reconciliation_kind
766                .as_deref()
767                .is_none_or(str::is_empty))
768        {
769            return Err(SecurityError::UnsafeSideEffect);
770        }
771        if self
772            .idempotency_key
773            .iter()
774            .chain(self.reconciliation_kind.iter())
775            .any(|value| value.len() > MAX_SECURITY_IDENTIFIER_BYTES)
776        {
777            return Err(SecurityError::InputLimitExceeded);
778        }
779        Ok(())
780    }
781}
782
783/// Secret text that zeroizes on drop, never serializes, and always redacts `Debug`/`Display`.
784pub struct SecretString(Zeroizing<String>);
785
786impl SecretString {
787    #[must_use]
788    pub fn new(value: String) -> Self {
789        Self(Zeroizing::new(value))
790    }
791
792    #[must_use]
793    pub fn expose_secret(&self) -> &str {
794        self.0.as_str()
795    }
796}
797
798impl fmt::Debug for SecretString {
799    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
800        formatter.write_str("SecretString([REDACTED])")
801    }
802}
803
804impl fmt::Display for SecretString {
805    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
806        formatter.write_str("[REDACTED]")
807    }
808}
809
810/// Typed, payload-free security events for audit and immediate alert routing.
811#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
812pub struct SecurityEvent {
813    pub event_id: SecurityEventId,
814    pub kind: SecurityEventKind,
815    pub tenant_id: Option<TenantId>,
816    pub principal_id: Option<ActorId>,
817    pub severity: SecuritySeverity,
818    pub occurred_at_unix_ms: u64,
819    pub reason_code: SecurityErrorCode,
820}
821
822#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
823pub enum SecurityEventKind {
824    AuthenticationFailure,
825    AuthorizationDenied,
826    DeviceRevokedAttempt,
827    ProtocolDowngradeRejected,
828    PayloadSubstitutionRejected,
829    CrossTenantAttempt,
830    SsrfBlocked,
831    AuthorityRollbackDetected,
832    ForkDetected,
833    AdminOverride,
834    LegacyWriteAfterCutover,
835    KeyRevoked,
836}
837
838#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
839pub enum SecuritySeverity {
840    Informational,
841    Low,
842    Medium,
843    High,
844    Critical,
845}
846
847/// Stable client-safe error codes; variants do not carry secrets or internal details.
848#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
849#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
850pub enum SecurityErrorCode {
851    AuthInvalid,
852    AuthExpired,
853    AuthRevoked,
854    AuthzDenied,
855    TenantMismatch,
856    ProtocolDowngrade,
857    PayloadMismatch,
858    InputLimitExceeded,
859    SnapshotSignatureInvalid,
860    AuthorityRollback,
861    SsrfBlocked,
862    UnsafeArchive,
863    UnsafeSideEffect,
864    Internal,
865}
866
867impl SecurityErrorCode {
868    #[must_use]
869    pub const fn as_str(self) -> &'static str {
870        match self {
871            Self::AuthInvalid => "AUTH_INVALID",
872            Self::AuthExpired => "AUTH_EXPIRED",
873            Self::AuthRevoked => "AUTH_REVOKED",
874            Self::AuthzDenied => "AUTHZ_DENIED",
875            Self::TenantMismatch => "TENANT_MISMATCH",
876            Self::ProtocolDowngrade => "PROTOCOL_DOWNGRADE",
877            Self::PayloadMismatch => "PAYLOAD_MISMATCH",
878            Self::InputLimitExceeded => "INPUT_LIMIT_EXCEEDED",
879            Self::SnapshotSignatureInvalid => "SNAPSHOT_SIGNATURE_INVALID",
880            Self::AuthorityRollback => "AUTHORITY_ROLLBACK",
881            Self::SsrfBlocked => "SSRF_BLOCKED",
882            Self::UnsafeArchive => "UNSAFE_ARCHIVE",
883            Self::UnsafeSideEffect => "UNSAFE_SIDE_EFFECT",
884            Self::Internal => "INTERNAL",
885        }
886    }
887}
888
889/// Metric series allowed by core. Identity values belong in access-controlled logs/traces.
890#[derive(Clone, Copy, Debug, Eq, PartialEq)]
891pub enum SecurityMetric {
892    AuthenticationFailure,
893    AuthorizationDenied,
894    DeviceRevokedAttempt,
895    ProtocolDowngradeRejected,
896    SsrfBlocked,
897    SignatureInvalid,
898    CrossTenantDenied,
899}
900
901impl SecurityMetric {
902    #[must_use]
903    pub const fn name(self) -> &'static str {
904        match self {
905            Self::AuthenticationFailure => "auth_failure_total",
906            Self::AuthorizationDenied => "authz_denied_total",
907            Self::DeviceRevokedAttempt => "device_revoked_attempt_total",
908            Self::ProtocolDowngradeRejected => "protocol_downgrade_rejected_total",
909            Self::SsrfBlocked => "ssrf_blocked_total",
910            Self::SignatureInvalid => "signature_invalid_total",
911            Self::CrossTenantDenied => "cross_tenant_denied_total",
912        }
913    }
914}
915
916/// Mandatory browser control that a host adapter must enforce.
917#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
918pub enum BrowserControl {
919    SecureCookie,
920    HttpOnlyCookie,
921    SameSiteCookie,
922    CsrfProtection,
923    Hsts,
924    ContentSecurityPolicy,
925    NoSniff,
926}
927
928/// Deployment control required by a security profile.
929#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
930pub enum DeploymentControl {
931    PrivateAdminListener,
932    MfaForDestructiveAdmin,
933    SignedArtifacts,
934    InternalMutualTls,
935    TwoPersonDestructiveApproval,
936}
937
938/// Complete security policy with explicit safe profiles.
939#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
940pub struct SecurityPolicy {
941    pub schema_version: u16,
942    pub level: SecurityLevel,
943    pub protocol: ProtocolLimits,
944    pub auth: AuthenticationPolicy,
945    pub egress: EgressPolicy,
946    pub uploads: UploadLimits,
947    pub browser_controls: BTreeSet<BrowserControl>,
948    pub deployment_controls: BTreeSet<DeploymentControl>,
949}
950
951impl SecurityPolicy {
952    #[must_use]
953    pub fn standard(trusted_issuer: impl Into<String>, audience: impl Into<String>) -> Self {
954        let trusted_issuer = trusted_issuer.into();
955        Self {
956            schema_version: SECURITY_SCHEMA_VERSION,
957            level: SecurityLevel::Standard,
958            protocol: ProtocolLimits {
959                max_frame_bytes: 4 * 1024 * 1024,
960                max_operations_per_batch: 1_000,
961                max_collection_items: 10_000,
962                max_nesting_depth: 64,
963                max_dependency_edges: 5_000,
964                max_string_bytes: 256 * 1024,
965                max_compressed_bytes: 4 * 1024 * 1024,
966                max_decompressed_bytes: 64 * 1024 * 1024,
967                max_compression_ratio: 64,
968            },
969            auth: AuthenticationPolicy {
970                trusted_issuers: BTreeSet::from([trusted_issuer]),
971                required_audience: audience.into(),
972                require_device_binding: true,
973                minimum_assurance: AssuranceLevel::Normal,
974                max_authentication_age_ms: 24 * 60 * 60 * 1_000,
975            },
976            egress: EgressPolicy {
977                allow_plain_http: false,
978                block_private_networks: true,
979                max_redirects: 0,
980                max_url_bytes: 2_048,
981            },
982            uploads: UploadLimits {
983                max_blob_bytes: 100 * 1024 * 1024,
984                max_chunks: 10_000,
985                max_archive_entries: 10_000,
986                max_archive_expanded_bytes: 512 * 1024 * 1024,
987                max_archive_ratio: 64,
988                max_path_bytes: 512,
989            },
990            browser_controls: BTreeSet::from([
991                BrowserControl::SecureCookie,
992                BrowserControl::HttpOnlyCookie,
993                BrowserControl::SameSiteCookie,
994                BrowserControl::CsrfProtection,
995                BrowserControl::Hsts,
996                BrowserControl::ContentSecurityPolicy,
997                BrowserControl::NoSniff,
998            ]),
999            deployment_controls: BTreeSet::from([
1000                DeploymentControl::PrivateAdminListener,
1001                DeploymentControl::MfaForDestructiveAdmin,
1002            ]),
1003        }
1004    }
1005
1006    #[must_use]
1007    pub fn enterprise(trusted_issuer: impl Into<String>, audience: impl Into<String>) -> Self {
1008        let mut policy = Self::standard(trusted_issuer, audience);
1009        policy.level = SecurityLevel::Enterprise;
1010        policy.auth.minimum_assurance = AssuranceLevel::MultiFactor;
1011        policy
1012            .deployment_controls
1013            .insert(DeploymentControl::SignedArtifacts);
1014        policy
1015            .deployment_controls
1016            .insert(DeploymentControl::InternalMutualTls);
1017        policy
1018    }
1019
1020    #[must_use]
1021    pub fn high_assurance(trusted_issuer: impl Into<String>, audience: impl Into<String>) -> Self {
1022        let mut policy = Self::enterprise(trusted_issuer, audience);
1023        policy.level = SecurityLevel::HighAssurance;
1024        policy.auth.minimum_assurance = AssuranceLevel::HardwareBacked;
1025        policy
1026            .deployment_controls
1027            .insert(DeploymentControl::TwoPersonDestructiveApproval);
1028        policy
1029    }
1030
1031    pub fn validate(&self) -> Result<(), SecurityError> {
1032        if self.schema_version != SECURITY_SCHEMA_VERSION {
1033            return Err(SecurityError::InvalidPolicy("schema version"));
1034        }
1035        self.protocol.validate()?;
1036        self.auth.validate()?;
1037        self.egress.validate()?;
1038        self.uploads.validate()?;
1039        let mandatory_browser = BTreeSet::from([
1040            BrowserControl::SecureCookie,
1041            BrowserControl::HttpOnlyCookie,
1042            BrowserControl::SameSiteCookie,
1043            BrowserControl::CsrfProtection,
1044            BrowserControl::NoSniff,
1045        ]);
1046        let mandatory_deployment = BTreeSet::from([
1047            DeploymentControl::PrivateAdminListener,
1048            DeploymentControl::MfaForDestructiveAdmin,
1049        ]);
1050        if !mandatory_browser.is_subset(&self.browser_controls)
1051            || !mandatory_deployment.is_subset(&self.deployment_controls)
1052        {
1053            return Err(SecurityError::UnsafeDefault);
1054        }
1055        match self.level {
1056            SecurityLevel::Standard => {}
1057            SecurityLevel::Enterprise => {
1058                if !self
1059                    .deployment_controls
1060                    .contains(&DeploymentControl::SignedArtifacts)
1061                    || !self
1062                        .deployment_controls
1063                        .contains(&DeploymentControl::InternalMutualTls)
1064                {
1065                    return Err(SecurityError::InvalidPolicy("enterprise requirements"));
1066                }
1067            }
1068            SecurityLevel::HighAssurance => {
1069                if !self
1070                    .deployment_controls
1071                    .contains(&DeploymentControl::SignedArtifacts)
1072                    || !self
1073                        .deployment_controls
1074                        .contains(&DeploymentControl::InternalMutualTls)
1075                    || !self
1076                        .deployment_controls
1077                        .contains(&DeploymentControl::TwoPersonDestructiveApproval)
1078                    || self.auth.minimum_assurance < AssuranceLevel::HardwareBacked
1079                {
1080                    return Err(SecurityError::InvalidPolicy("high-assurance requirements"));
1081                }
1082            }
1083        }
1084        Ok(())
1085    }
1086}
1087
1088/// Critical assets enumerated by the normative threat model.
1089#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1090pub enum SecurityAsset {
1091    AuthoritativeState,
1092    OperationLedger,
1093    Journal,
1094    AuditTrail,
1095    TenantData,
1096    IdentityAndAuthorization,
1097    CryptographicKeys,
1098    AuthorityMetadata,
1099    GovernanceControls,
1100    ProviderReferences,
1101    AdminControlPlane,
1102}
1103
1104/// Attacker classes that every subsystem threat review must consider.
1105#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1106pub enum AttackerClass {
1107    UnauthenticatedInternet,
1108    AuthenticatedMaliciousUser,
1109    CompromisedClientDevice,
1110    MaliciousTenantAdministrator,
1111    CompromisedApplicationNode,
1112    MaliciousInsider,
1113    CompromisedProvider,
1114    NetworkAttacker,
1115    SupplyChainAttacker,
1116    ResourceExhaustionAttacker,
1117}
1118
1119/// Explicit boundary where data must be authenticated, authorized, and bounded again.
1120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1121pub enum TrustBoundary {
1122    ClientInternet,
1123    InternetDataPlane,
1124    AdminControlPlane,
1125    ServerDatabase,
1126    ServerObjectStorage,
1127    ServerKms,
1128    ServerProvider,
1129    WorkerProvider,
1130    ReplicaAuthority,
1131    LegacyBridgeSystem,
1132}
1133
1134pub const SECURITY_ASSETS: [SecurityAsset; 11] = [
1135    SecurityAsset::AuthoritativeState,
1136    SecurityAsset::OperationLedger,
1137    SecurityAsset::Journal,
1138    SecurityAsset::AuditTrail,
1139    SecurityAsset::TenantData,
1140    SecurityAsset::IdentityAndAuthorization,
1141    SecurityAsset::CryptographicKeys,
1142    SecurityAsset::AuthorityMetadata,
1143    SecurityAsset::GovernanceControls,
1144    SecurityAsset::ProviderReferences,
1145    SecurityAsset::AdminControlPlane,
1146];
1147
1148pub const ATTACKER_CLASSES: [AttackerClass; 10] = [
1149    AttackerClass::UnauthenticatedInternet,
1150    AttackerClass::AuthenticatedMaliciousUser,
1151    AttackerClass::CompromisedClientDevice,
1152    AttackerClass::MaliciousTenantAdministrator,
1153    AttackerClass::CompromisedApplicationNode,
1154    AttackerClass::MaliciousInsider,
1155    AttackerClass::CompromisedProvider,
1156    AttackerClass::NetworkAttacker,
1157    AttackerClass::SupplyChainAttacker,
1158    AttackerClass::ResourceExhaustionAttacker,
1159];
1160
1161pub const TRUST_BOUNDARIES: [TrustBoundary; 10] = [
1162    TrustBoundary::ClientInternet,
1163    TrustBoundary::InternetDataPlane,
1164    TrustBoundary::AdminControlPlane,
1165    TrustBoundary::ServerDatabase,
1166    TrustBoundary::ServerObjectStorage,
1167    TrustBoundary::ServerKms,
1168    TrustBoundary::ServerProvider,
1169    TrustBoundary::WorkerProvider,
1170    TrustBoundary::ReplicaAuthority,
1171    TrustBoundary::LegacyBridgeSystem,
1172];
1173
1174/// Stable Part 27 invariant registry entry.
1175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1176pub struct SecurityInvariant {
1177    pub id: &'static str,
1178    pub summary: &'static str,
1179    pub regression_test: &'static str,
1180    pub metric: &'static str,
1181}
1182
1183pub const SECURITY_INVARIANTS: [SecurityInvariant; 10] = [
1184    SecurityInvariant {
1185        id: "AEQ-INV-SEC001",
1186        summary: "client claims are never authorization evidence",
1187        regression_test: "authentication_and_tenant_binding",
1188        metric: "authz_denied_total",
1189    },
1190    SecurityInvariant {
1191        id: "AEQ-INV-SEC002",
1192        summary: "operation identity binds immutable semantics",
1193        regression_test: "operation_payload_substitution",
1194        metric: "payload_mismatch_total",
1195    },
1196    SecurityInvariant {
1197        id: "AEQ-INV-SEC003",
1198        summary: "required security capabilities fail closed",
1199        regression_test: "required_security_capability_downgrade",
1200        metric: "protocol_downgrade_rejected_total",
1201    },
1202    SecurityInvariant {
1203        id: "AEQ-INV-SEC004",
1204        summary: "external input has explicit complexity bounds",
1205        regression_test: "boundary_plus_one_and_archive_bomb",
1206        metric: "input_limit_rejected_total",
1207    },
1208    SecurityInvariant {
1209        id: "AEQ-INV-SEC005",
1210        summary: "known identifiers never bypass tenant isolation",
1211        regression_test: "cross_tenant_resource_matrix",
1212        metric: "cross_tenant_denied_total",
1213    },
1214    SecurityInvariant {
1215        id: "AEQ-INV-SEC006",
1216        summary: "keys and auth secrets never enter ordinary output",
1217        regression_test: "secret_redaction_and_serialization_exclusion",
1218        metric: "secret_exposure_total",
1219    },
1220    SecurityInvariant {
1221        id: "AEQ-INV-SEC007",
1222        summary: "authority rollback fails closed",
1223        regression_test: "authority_epoch_rollback",
1224        metric: "authority_rollback_total",
1225    },
1226    SecurityInvariant {
1227        id: "AEQ-INV-SEC008",
1228        summary: "irreversible side effects reconcile idempotently",
1229        regression_test: "side_effect_safety",
1230        metric: "unsafe_side_effect_total",
1231    },
1232    SecurityInvariant {
1233        id: "AEQ-INV-SEC009",
1234        summary: "admin overrides require stronger auth and audit",
1235        regression_test: "admin_override_policy",
1236        metric: "admin_override_total",
1237    },
1238    SecurityInvariant {
1239        id: "AEQ-INV-SEC010",
1240        summary: "integration inputs remain untrusted",
1241        regression_test: "ssrf_archive_and_provider_input",
1242        metric: "untrusted_input_rejected_total",
1243    },
1244];
1245
1246#[derive(Debug, Error, Eq, PartialEq)]
1247pub enum SecurityError {
1248    #[error("security policy is invalid: {0}")]
1249    InvalidPolicy(&'static str),
1250    #[error("authentication evidence is invalid")]
1251    AuthenticationInvalid,
1252    #[error("authentication evidence is expired")]
1253    AuthenticationExpired,
1254    #[error("authentication assurance is insufficient")]
1255    InsufficientAssurance,
1256    #[error("device binding is required")]
1257    DeviceBindingRequired,
1258    #[error("device is revoked")]
1259    DeviceRevoked,
1260    #[error("tenant claim does not match authenticated identity")]
1261    TenantMismatch,
1262    #[error("resource was not found or is forbidden")]
1263    NotFoundOrForbidden,
1264    #[error("input exceeds a configured bound")]
1265    InputLimitExceeded,
1266    #[error("compressed input exceeds expansion bounds")]
1267    CompressionLimitExceeded,
1268    #[error("checked security arithmetic overflowed")]
1269    ArithmeticOverflow,
1270    #[error("archive contains an unsafe path")]
1271    UnsafeArchivePath,
1272    #[error("archive contains a forbidden entry type")]
1273    UnsafeArchiveEntry,
1274    #[error("archive exceeds configured bounds")]
1275    ArchiveLimitExceeded,
1276    #[error("outbound target was blocked by SSRF policy")]
1277    SsrfBlocked,
1278    #[error("operation identity was reused with different semantics")]
1279    PayloadMismatch,
1280    #[error("authority epoch rollback detected")]
1281    AuthorityRollback,
1282    #[error("side effect lacks idempotency or reconciliation")]
1283    UnsafeSideEffect,
1284    #[error("security policy weakens a mandatory safe default")]
1285    UnsafeDefault,
1286}
1287
1288impl SecurityError {
1289    #[must_use]
1290    pub const fn code(&self) -> SecurityErrorCode {
1291        match self {
1292            Self::AuthenticationInvalid => SecurityErrorCode::AuthInvalid,
1293            Self::AuthenticationExpired => SecurityErrorCode::AuthExpired,
1294            Self::DeviceRevoked => SecurityErrorCode::AuthRevoked,
1295            Self::TenantMismatch => SecurityErrorCode::TenantMismatch,
1296            Self::NotFoundOrForbidden
1297            | Self::InsufficientAssurance
1298            | Self::DeviceBindingRequired => SecurityErrorCode::AuthzDenied,
1299            Self::PayloadMismatch => SecurityErrorCode::PayloadMismatch,
1300            Self::InputLimitExceeded
1301            | Self::CompressionLimitExceeded
1302            | Self::ArithmeticOverflow => SecurityErrorCode::InputLimitExceeded,
1303            Self::UnsafeArchivePath | Self::UnsafeArchiveEntry | Self::ArchiveLimitExceeded => {
1304                SecurityErrorCode::UnsafeArchive
1305            }
1306            Self::SsrfBlocked => SecurityErrorCode::SsrfBlocked,
1307            Self::AuthorityRollback => SecurityErrorCode::AuthorityRollback,
1308            Self::UnsafeSideEffect => SecurityErrorCode::UnsafeSideEffect,
1309            Self::InvalidPolicy(_) | Self::UnsafeDefault => SecurityErrorCode::Internal,
1310        }
1311    }
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316    use super::*;
1317    use std::net::{IpAddr, Ipv6Addr};
1318
1319    fn evidence() -> AuthenticationEvidence {
1320        AuthenticationEvidence {
1321            principal_id: ActorId::new(),
1322            tenant_id: TenantId::new(),
1323            device_id: Some(DeviceId::new()),
1324            auth_method: AuthMethod::BearerToken,
1325            assurance: AssuranceLevel::MultiFactor,
1326            issuer: "https://identity.example".to_owned(),
1327            audience: "aequora".to_owned(),
1328            authenticated_at_unix_ms: 100,
1329            expires_at_unix_ms: 1_000,
1330            device_state: Some(DeviceState::Active),
1331        }
1332    }
1333
1334    #[test]
1335    fn safe_profiles_validate() {
1336        assert!(
1337            SecurityPolicy::standard("issuer", "audience")
1338                .validate()
1339                .is_ok()
1340        );
1341        assert!(
1342            SecurityPolicy::enterprise("issuer", "audience")
1343                .validate()
1344                .is_ok()
1345        );
1346        assert!(
1347            SecurityPolicy::high_assurance("issuer", "audience")
1348                .validate()
1349                .is_ok()
1350        );
1351    }
1352
1353    #[test]
1354    fn authentication_and_tenant_binding_fail_closed() {
1355        let policy = SecurityPolicy::standard("https://identity.example", "aequora");
1356        let context = policy
1357            .auth
1358            .authenticate(evidence(), 200)
1359            .unwrap_or_else(|error| panic!("unexpected: {error}"));
1360        assert_eq!(
1361            context.bind_tenant(TenantId::new()),
1362            Err(SecurityError::TenantMismatch)
1363        );
1364
1365        let mut revoked = evidence();
1366        revoked.device_state = Some(DeviceState::Revoked);
1367        assert_eq!(
1368            policy.auth.authenticate(revoked, 200),
1369            Err(SecurityError::DeviceRevoked)
1370        );
1371    }
1372
1373    #[test]
1374    fn operation_payload_substitution_is_rejected() {
1375        let auth = evidence();
1376        let operation_id = OperationId::new();
1377        let first = OperationBinding::new(
1378            operation_id,
1379            auth.tenant_id,
1380            auth.principal_id,
1381            auth.device_id,
1382            b"first",
1383        );
1384        let changed = OperationBinding::new(
1385            operation_id,
1386            auth.tenant_id,
1387            auth.principal_id,
1388            auth.device_id,
1389            b"changed",
1390        );
1391        let mut guard =
1392            OperationReplayGuard::new(2).unwrap_or_else(|error| panic!("unexpected: {error}"));
1393        assert_eq!(guard.observe(first), Ok(ReplayDisposition::FirstSeen));
1394        assert_eq!(guard.observe(first), Ok(ReplayDisposition::Duplicate));
1395        assert_eq!(guard.observe(changed), Err(SecurityError::PayloadMismatch));
1396    }
1397
1398    #[test]
1399    fn boundary_plus_one_and_compression_bomb_are_rejected() {
1400        let limits = SecurityPolicy::standard("issuer", "audience").protocol;
1401        let shape = InputShape {
1402            frame_bytes: limits.max_frame_bytes + 1,
1403            ..InputShape::default()
1404        };
1405        assert_eq!(
1406            limits.validate_input(shape),
1407            Err(SecurityError::InputLimitExceeded)
1408        );
1409        assert_eq!(
1410            limits.validate_compression(1, limits.max_compression_ratio + 1),
1411            Err(SecurityError::CompressionLimitExceeded)
1412        );
1413    }
1414
1415    #[test]
1416    fn ssrf_blocks_private_metadata_redirect_and_rebinding() {
1417        let policy = SecurityPolicy::standard("issuer", "audience").egress;
1418        for address in [
1419            IpAddr::from([127, 0, 0, 1]),
1420            IpAddr::from([10, 0, 0, 1]),
1421            IpAddr::from([172, 16, 0, 1]),
1422            IpAddr::from([192, 168, 0, 1]),
1423            IpAddr::from([169, 254, 169, 254]),
1424            IpAddr::V6(Ipv6Addr::LOCALHOST),
1425            "fc00::1"
1426                .parse::<IpAddr>()
1427                .unwrap_or(IpAddr::V6(Ipv6Addr::LOCALHOST)),
1428            "::ffff:127.0.0.1"
1429                .parse::<IpAddr>()
1430                .unwrap_or(IpAddr::V6(Ipv6Addr::LOCALHOST)),
1431        ] {
1432            assert_eq!(
1433                policy.validate_target("https://example.test/hook", &[address], 0),
1434                Err(SecurityError::SsrfBlocked)
1435            );
1436        }
1437        let public = IpAddr::from([93, 184, 216, 34]);
1438        let target = policy
1439            .validate_target("https://example.com/hook", &[public], 0)
1440            .unwrap_or_else(|error| panic!("unexpected: {error}"));
1441        assert_eq!(
1442            target.revalidate_connection(policy, IpAddr::from([127, 0, 0, 1])),
1443            Err(SecurityError::SsrfBlocked)
1444        );
1445        assert_eq!(
1446            policy.validate_target("https://example.com/next", &[public], 1),
1447            Err(SecurityError::SsrfBlocked)
1448        );
1449    }
1450
1451    #[test]
1452    fn archive_traversal_links_and_bombs_are_rejected() {
1453        let limits = SecurityPolicy::standard("issuer", "audience").uploads;
1454        let traversal = ArchiveEntry {
1455            relative_path: "../../etc/passwd".to_owned(),
1456            kind: ArchiveEntryKind::RegularFile,
1457            compressed_bytes: 1,
1458            expanded_bytes: 1,
1459        };
1460        assert_eq!(
1461            limits.validate_archive(&[traversal]),
1462            Err(SecurityError::UnsafeArchivePath)
1463        );
1464        let link = ArchiveEntry {
1465            relative_path: "link".to_owned(),
1466            kind: ArchiveEntryKind::SymbolicLink,
1467            compressed_bytes: 1,
1468            expanded_bytes: 1,
1469        };
1470        assert_eq!(
1471            limits.validate_archive(&[link]),
1472            Err(SecurityError::UnsafeArchiveEntry)
1473        );
1474        let bomb = ArchiveEntry {
1475            relative_path: "bomb.bin".to_owned(),
1476            kind: ArchiveEntryKind::RegularFile,
1477            compressed_bytes: 1,
1478            expanded_bytes: limits.max_archive_ratio + 1,
1479        };
1480        assert_eq!(
1481            limits.validate_archive(&[bomb]),
1482            Err(SecurityError::ArchiveLimitExceeded)
1483        );
1484    }
1485
1486    #[test]
1487    fn secret_output_is_always_redacted() {
1488        let secret = SecretString::new("bearer-super-secret".to_owned());
1489        assert_eq!(format!("{secret}"), "[REDACTED]");
1490        assert_eq!(format!("{secret:?}"), "SecretString([REDACTED])");
1491        assert_eq!(secret.expose_secret(), "bearer-super-secret");
1492    }
1493
1494    #[test]
1495    fn authority_and_side_effect_guards_fail_closed() {
1496        let epoch_two = AuthorityEpoch::new(2).unwrap_or(AuthorityEpoch::INITIAL);
1497        let mut guard = AuthorityEpochGuard::new(epoch_two);
1498        assert_eq!(
1499            guard.observe(AuthorityEpoch::INITIAL),
1500            Err(SecurityError::AuthorityRollback)
1501        );
1502        let unsafe_effect = SideEffectSafety {
1503            risk: SideEffectRisk::Financial,
1504            idempotency_key: None,
1505            reconciliation_kind: None,
1506        };
1507        assert_eq!(
1508            unsafe_effect.validate(),
1509            Err(SecurityError::UnsafeSideEffect)
1510        );
1511    }
1512
1513    #[test]
1514    fn invariant_registry_is_unique_and_complete() {
1515        let ids = SECURITY_INVARIANTS
1516            .iter()
1517            .map(|entry| entry.id)
1518            .collect::<BTreeSet<_>>();
1519        assert_eq!(ids.len(), 10);
1520        assert_eq!(SECURITY_ASSETS.len(), 11);
1521        assert_eq!(ATTACKER_CLASSES.len(), 10);
1522        assert_eq!(TRUST_BOUNDARIES.len(), 10);
1523    }
1524}