1#![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
23pub const SECURITY_SCHEMA_VERSION: u16 = 1;
25pub 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#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
66pub enum SecurityLevel {
67 Standard,
68 Enterprise,
69 HighAssurance,
70}
71
72#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
74pub enum AuthMethod {
75 BearerToken,
76 CookieSession,
77 MutualTls,
78 WorkloadIdentity,
79 DeviceSignature,
80}
81
82#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
84pub enum AssuranceLevel {
85 Normal,
86 MultiFactor,
87 HardwareBacked,
88 BreakGlass,
89}
90
91#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
93pub enum DeviceState {
94 Active,
95 VerificationOnly,
96 Revoked,
97}
98
99#[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#[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#[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#[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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
256pub struct TenantResource {
257 pub tenant_id: TenantId,
258 pub kind: ResourceKind,
259}
260
261#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
783pub 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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}