1use crate::verification::VerificationCase;
8use sha2::{Digest, Sha256};
9use std::{
10 collections::{BTreeMap, BTreeSet},
11 error::Error,
12 fmt,
13};
14use zeroize::Zeroize;
15
16const TOKEN_BYTES: usize = 32;
17const EVENT_KEY_BYTES: usize = 32;
18const MAX_ASSESSMENT_ID_BYTES: usize = 256;
19const MAX_VERIFICATION_BINDING_COMPONENT_BYTES: usize = 256;
20const HARD_MAX_REGISTRATIONS: u16 = 4_096;
21const HARD_MAX_POLLS: u16 = 64;
22const HARD_MAX_EVENTS_PER_POLL: u16 = 64;
23const HARD_MAX_UNIQUE_EVENTS: u16 = 1_024;
24const HARD_MAX_LIFETIME_MILLIS: u64 = 86_400_000;
25
26const TOKEN_REUSE_DOMAIN: &[u8] = b"security.oast-correlation.token-reuse.v1\0";
27const BINDING_ID_DOMAIN: &[u8] = b"security.oast-correlation.binding.v1\0";
28const CORRELATION_ID_DOMAIN: &[u8] = b"security.oast-correlation.id.v1\0";
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum OastError {
34 EmptyAssessmentId,
36 AssessmentIdTooLong {
38 actual: usize,
40 maximum: usize,
42 },
43 InvalidAssessmentId,
45 ZeroOpaqueId,
47 ZeroLifetime,
49 LifetimeTooLong {
51 actual: u64,
53 maximum: u64,
55 },
56 ZeroPollBudget,
58 PollBudgetTooLarge {
60 actual: u16,
62 maximum: u16,
64 },
65 ZeroAuthorityLimit,
67 AuthorityLimitTooLarge,
69 EmptyProtocolSet,
71 RegistrationCapacityExhausted,
73 TokenAlreadyRegistered,
75 VerificationBindingTooLarge,
77 LifetimeGrantExceeded {
79 requested: u64,
81 maximum: u64,
83 },
84 PollGrantExceeded {
86 requested: u16,
88 maximum: u16,
90 },
91 ExpiryOverflow,
93 BindingMismatch,
95 CorrelationMismatch,
97 ProtocolNotAllowed {
99 protocol: OastEventProtocol,
101 },
102 ClockRegressed {
104 previous: OastMonotonicTime,
106 current: OastMonotonicTime,
108 },
109 Expired {
111 expires_at: OastMonotonicTime,
113 },
114 Cancelled,
116 PollBudgetExhausted,
118 PollEventLimitExceeded {
120 maximum: u16,
122 },
123 UniqueEventLimitExceeded {
125 maximum: u16,
127 },
128 EventKeyProtocolConflict,
130}
131
132impl fmt::Display for OastError {
133 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134 match self {
135 Self::EmptyAssessmentId => formatter.write_str("OAST assessment id is empty"),
136 Self::AssessmentIdTooLong { actual, maximum } => {
137 write!(
138 formatter,
139 "OAST assessment id length {actual} exceeds {maximum}"
140 )
141 },
142 Self::InvalidAssessmentId => formatter.write_str("OAST assessment id is invalid"),
143 Self::ZeroOpaqueId => formatter.write_str("OAST opaque id must not be all zero"),
144 Self::ZeroLifetime => formatter.write_str("OAST lifetime must be non-zero"),
145 Self::LifetimeTooLong { actual, maximum } => {
146 write!(formatter, "OAST lifetime {actual}ms exceeds {maximum}ms")
147 },
148 Self::ZeroPollBudget => formatter.write_str("OAST poll budget must be non-zero"),
149 Self::PollBudgetTooLarge { actual, maximum } => {
150 write!(formatter, "OAST poll budget {actual} exceeds {maximum}")
151 },
152 Self::ZeroAuthorityLimit => {
153 formatter.write_str("OAST authority limits must be non-zero")
154 },
155 Self::AuthorityLimitTooLarge => {
156 formatter.write_str("OAST authority limit exceeds a hard foundation bound")
157 },
158 Self::EmptyProtocolSet => formatter.write_str("OAST protocol set must not be empty"),
159 Self::RegistrationCapacityExhausted => {
160 formatter.write_str("OAST registration capacity is exhausted")
161 },
162 Self::TokenAlreadyRegistered => {
163 formatter.write_str("OAST token was already registered in this authority epoch")
164 },
165 Self::VerificationBindingTooLarge => {
166 formatter.write_str("OAST verification binding exceeds its component bound")
167 },
168 Self::LifetimeGrantExceeded { requested, maximum } => {
169 write!(
170 formatter,
171 "OAST lifetime {requested}ms exceeds grant {maximum}ms"
172 )
173 },
174 Self::PollGrantExceeded { requested, maximum } => {
175 write!(
176 formatter,
177 "OAST poll count {requested} exceeds grant {maximum}"
178 )
179 },
180 Self::ExpiryOverflow => formatter.write_str("OAST expiry overflowed monotonic time"),
181 Self::BindingMismatch => formatter.write_str("OAST binding does not match"),
182 Self::CorrelationMismatch => formatter.write_str("OAST correlation does not match"),
183 Self::ProtocolNotAllowed { protocol } => {
184 write!(formatter, "OAST protocol {protocol:?} is not allowed")
185 },
186 Self::ClockRegressed { previous, current } => write!(
187 formatter,
188 "OAST time regressed from {}ms to {}ms",
189 previous.as_millis(),
190 current.as_millis()
191 ),
192 Self::Expired { expires_at } => {
193 write!(
194 formatter,
195 "OAST correlation expired at {}ms",
196 expires_at.as_millis()
197 )
198 },
199 Self::Cancelled => formatter.write_str("OAST correlation is cancelled"),
200 Self::PollBudgetExhausted => formatter.write_str("OAST poll budget is exhausted"),
201 Self::PollEventLimitExceeded { maximum } => {
202 write!(formatter, "OAST poll exceeds its {maximum}-event bound")
203 },
204 Self::UniqueEventLimitExceeded { maximum } => {
205 write!(
206 formatter,
207 "OAST registration exceeds its {maximum}-event bound"
208 )
209 },
210 Self::EventKeyProtocolConflict => {
211 formatter.write_str("OAST event key has conflicting protocol families")
212 },
213 }
214 }
215}
216
217impl Error for OastError {}
218
219#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
221pub struct OastAssessmentId(String);
222
223impl OastAssessmentId {
224 pub fn new(value: impl AsRef<str>) -> Result<Self, OastError> {
226 let value = value.as_ref();
227 if value.is_empty() {
228 return Err(OastError::EmptyAssessmentId);
229 }
230 if value.len() > MAX_ASSESSMENT_ID_BYTES {
231 return Err(OastError::AssessmentIdTooLong {
232 actual: value.len(),
233 maximum: MAX_ASSESSMENT_ID_BYTES,
234 });
235 }
236 if !value
237 .bytes()
238 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':'))
239 {
240 return Err(OastError::InvalidAssessmentId);
241 }
242 Ok(Self(value.to_owned()))
243 }
244
245 pub fn as_str(&self) -> &str {
247 &self.0
248 }
249}
250
251impl fmt::Debug for OastAssessmentId {
252 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
253 formatter.write_str("OastAssessmentId(<redacted>)")
254 }
255}
256
257pub struct OastCorrelationToken {
263 secret_bytes: [u8; 32],
264}
265
266impl OastCorrelationToken {
267 pub fn new(secret_bytes: [u8; TOKEN_BYTES]) -> Result<Self, OastError> {
269 reject_zero(&secret_bytes)?;
270 Ok(Self { secret_bytes })
271 }
272
273 fn erase(&mut self) {
274 self.secret_bytes.zeroize();
275 }
276}
277
278impl fmt::Debug for OastCorrelationToken {
279 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
280 formatter.write_str("OastCorrelationToken(<redacted>)")
281 }
282}
283
284impl Drop for OastCorrelationToken {
285 fn drop(&mut self) {
286 self.erase();
287 }
288}
289
290#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
292pub struct OastAuthorityEpoch([u8; 32]);
293
294impl OastAuthorityEpoch {
295 pub fn new(bytes: [u8; 32]) -> Result<Self, OastError> {
297 reject_zero(&bytes)?;
298 Ok(Self(bytes))
299 }
300
301 #[cfg(feature = "ssrf-oast-review")]
303 pub(crate) const fn into_bytes(self) -> [u8; 32] {
304 self.0
305 }
306}
307
308impl fmt::Debug for OastAuthorityEpoch {
309 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
310 formatter.write_str("OastAuthorityEpoch(<redacted>)")
311 }
312}
313
314#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
316pub struct OastBindingId([u8; 32]);
317
318impl fmt::Debug for OastBindingId {
319 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
320 formatter.write_str("OastBindingId(<redacted>)")
321 }
322}
323
324#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
326pub struct OastCorrelationId([u8; 32]);
327
328impl fmt::Debug for OastCorrelationId {
329 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
330 formatter.write_str("OastCorrelationId(<redacted>)")
331 }
332}
333
334#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
336pub struct OastEventKey([u8; 32]);
337
338impl OastEventKey {
339 pub fn new(bytes: [u8; EVENT_KEY_BYTES]) -> Result<Self, OastError> {
341 reject_zero(&bytes)?;
342 Ok(Self(bytes))
343 }
344
345 #[cfg(feature = "ssrf-oast-review")]
347 pub(crate) const fn as_bytes(&self) -> &[u8; EVENT_KEY_BYTES] {
348 &self.0
349 }
350}
351
352impl fmt::Debug for OastEventKey {
353 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
354 formatter.write_str("OastEventKey(<redacted>)")
355 }
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
360pub struct OastMonotonicTime(u64);
361
362impl OastMonotonicTime {
363 pub const fn from_millis(milliseconds: u64) -> Self {
365 Self(milliseconds)
366 }
367
368 pub const fn as_millis(self) -> u64 {
370 self.0
371 }
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub struct OastLifetime(u64);
377
378impl OastLifetime {
379 pub fn from_millis(milliseconds: u64) -> Result<Self, OastError> {
381 if milliseconds == 0 {
382 return Err(OastError::ZeroLifetime);
383 }
384 if milliseconds > HARD_MAX_LIFETIME_MILLIS {
385 return Err(OastError::LifetimeTooLong {
386 actual: milliseconds,
387 maximum: HARD_MAX_LIFETIME_MILLIS,
388 });
389 }
390 Ok(Self(milliseconds))
391 }
392
393 pub const fn as_millis(self) -> u64 {
395 self.0
396 }
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub struct OastPollBudget(u16);
402
403impl OastPollBudget {
404 pub fn new(polls: u16) -> Result<Self, OastError> {
406 if polls == 0 {
407 return Err(OastError::ZeroPollBudget);
408 }
409 if polls > HARD_MAX_POLLS {
410 return Err(OastError::PollBudgetTooLarge {
411 actual: polls,
412 maximum: HARD_MAX_POLLS,
413 });
414 }
415 Ok(Self(polls))
416 }
417
418 pub const fn polls(self) -> u16 {
420 self.0
421 }
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub struct OastAuthorityLimits {
427 max_registrations: u16,
428 max_polls_per_registration: u16,
429 max_events_per_poll: u16,
430 max_unique_events_per_registration: u16,
431 max_lifetime_millis: u64,
432}
433
434impl OastAuthorityLimits {
435 pub fn new(
437 max_registrations: u16,
438 max_polls_per_registration: u16,
439 max_events_per_poll: u16,
440 max_unique_events_per_registration: u16,
441 max_lifetime_millis: u64,
442 ) -> Result<Self, OastError> {
443 if max_registrations == 0
444 || max_polls_per_registration == 0
445 || max_events_per_poll == 0
446 || max_unique_events_per_registration == 0
447 || max_lifetime_millis == 0
448 {
449 return Err(OastError::ZeroAuthorityLimit);
450 }
451 if max_registrations > HARD_MAX_REGISTRATIONS
452 || max_polls_per_registration > HARD_MAX_POLLS
453 || max_events_per_poll > HARD_MAX_EVENTS_PER_POLL
454 || max_unique_events_per_registration > HARD_MAX_UNIQUE_EVENTS
455 || max_lifetime_millis > HARD_MAX_LIFETIME_MILLIS
456 {
457 return Err(OastError::AuthorityLimitTooLarge);
458 }
459 Ok(Self {
460 max_registrations,
461 max_polls_per_registration,
462 max_events_per_poll,
463 max_unique_events_per_registration,
464 max_lifetime_millis,
465 })
466 }
467
468 pub const fn max_registrations(self) -> u16 {
470 self.max_registrations
471 }
472
473 pub const fn max_polls_per_registration(self) -> u16 {
475 self.max_polls_per_registration
476 }
477
478 pub const fn max_events_per_poll(self) -> u16 {
480 self.max_events_per_poll
481 }
482
483 pub const fn max_unique_events_per_registration(self) -> u16 {
485 self.max_unique_events_per_registration
486 }
487
488 pub const fn max_lifetime_millis(self) -> u64 {
490 self.max_lifetime_millis
491 }
492}
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
496pub struct OastProtocolSet(u8);
497
498impl OastProtocolSet {
499 const DNS: u8 = 1;
500 const HTTP: u8 = 2;
501
502 pub fn new(allow_dns: bool, allow_http: bool) -> Result<Self, OastError> {
504 let bits = (u8::from(allow_dns) * Self::DNS) | (u8::from(allow_http) * Self::HTTP);
505 if bits == 0 {
506 return Err(OastError::EmptyProtocolSet);
507 }
508 Ok(Self(bits))
509 }
510
511 pub const fn allows(self, protocol: OastEventProtocol) -> bool {
513 let bit = match protocol {
514 OastEventProtocol::Dns => Self::DNS,
515 OastEventProtocol::Http => Self::HTTP,
516 };
517 self.0 & bit != 0
518 }
519
520 pub const fn allows_dns(self) -> bool {
522 self.0 & Self::DNS != 0
523 }
524
525 pub const fn allows_http(self) -> bool {
527 self.0 & Self::HTTP != 0
528 }
529}
530
531#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
533pub enum OastDnsTransport {
534 Udp,
536 Tcp,
538}
539
540#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
542#[non_exhaustive]
543pub enum OastDnsRecordType {
544 A,
546 Aaaa,
548 Cname,
550 Mx,
552 Ns,
554 Ptr,
556 Srv,
558 Txt,
560 Other,
562}
563
564#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
566pub struct OastDnsEvent {
567 transport: OastDnsTransport,
568 record_type: OastDnsRecordType,
569}
570
571impl OastDnsEvent {
572 pub const fn new(transport: OastDnsTransport, record_type: OastDnsRecordType) -> Self {
574 Self {
575 transport,
576 record_type,
577 }
578 }
579
580 pub const fn transport(self) -> OastDnsTransport {
582 self.transport
583 }
584
585 pub const fn record_type(self) -> OastDnsRecordType {
587 self.record_type
588 }
589}
590
591#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
593pub enum OastHttpScheme {
594 Http,
596 Https,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
602#[non_exhaustive]
603pub enum OastHttpMethod {
604 Unknown,
609 Get,
611 Head,
613 Post,
615 Put,
617 Patch,
619 Delete,
621 Options,
623 Trace,
625 Connect,
627 Other,
629}
630
631#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
633pub struct OastHttpEvent {
634 scheme: OastHttpScheme,
635 method: OastHttpMethod,
636 body_present: bool,
637}
638
639impl OastHttpEvent {
640 pub const fn new(scheme: OastHttpScheme, method: OastHttpMethod, body_present: bool) -> Self {
642 Self {
643 scheme,
644 method,
645 body_present,
646 }
647 }
648
649 pub const fn scheme(self) -> OastHttpScheme {
651 self.scheme
652 }
653
654 pub const fn method(self) -> OastHttpMethod {
656 self.method
657 }
658
659 pub const fn body_present(self) -> bool {
661 self.body_present
662 }
663}
664
665#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
667pub enum OastEventProtocol {
668 Dns,
670 Http,
672}
673
674#[derive(Debug, Clone, PartialEq, Eq)]
676#[non_exhaustive]
677pub enum OastEvent {
678 Dns(OastEventKey, OastDnsEvent),
680 Http(OastEventKey, OastHttpEvent),
682}
683
684impl OastEvent {
685 pub fn key(&self) -> &OastEventKey {
687 match self {
688 Self::Dns(key, _) | Self::Http(key, _) => key,
689 }
690 }
691
692 pub const fn protocol(&self) -> OastEventProtocol {
694 match self {
695 Self::Dns(_, _) => OastEventProtocol::Dns,
696 Self::Http(_, _) => OastEventProtocol::Http,
697 }
698 }
699}
700
701#[derive(Debug, Clone, Copy, PartialEq, Eq)]
703pub enum OastEventDisposition {
704 Accepted,
706 DuplicateSuppressed,
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq)]
712pub enum OastCorrelationState {
713 Active,
715 Cancelled,
717 Expired,
719}
720
721#[derive(Debug, Clone, PartialEq, Eq)]
723pub struct OastRegistrationReceipt {
724 binding_id: OastBindingId,
725 correlation_id: OastCorrelationId,
726 issued_at: OastMonotonicTime,
727 expires_at: OastMonotonicTime,
728 poll_limit: u16,
729 allowed_protocols: OastProtocolSet,
730}
731
732impl OastRegistrationReceipt {
733 pub fn binding_id(&self) -> &OastBindingId {
735 &self.binding_id
736 }
737
738 pub fn correlation_id(&self) -> &OastCorrelationId {
740 &self.correlation_id
741 }
742
743 pub const fn issued_at(&self) -> OastMonotonicTime {
745 self.issued_at
746 }
747
748 pub const fn expires_at(&self) -> OastMonotonicTime {
750 self.expires_at
751 }
752
753 pub const fn poll_limit(&self) -> u16 {
755 self.poll_limit
756 }
757
758 pub const fn allowed_protocols(&self) -> OastProtocolSet {
760 self.allowed_protocols
761 }
762}
763
764#[derive(Debug, Clone, PartialEq, Eq)]
766pub struct OastEventReceipt {
767 event_key: OastEventKey,
768 protocol: OastEventProtocol,
769 disposition: OastEventDisposition,
770 observed_at: OastMonotonicTime,
771}
772
773impl OastEventReceipt {
774 pub fn event_key(&self) -> &OastEventKey {
776 &self.event_key
777 }
778
779 pub const fn protocol(&self) -> OastEventProtocol {
781 self.protocol
782 }
783
784 pub const fn disposition(&self) -> OastEventDisposition {
786 self.disposition
787 }
788
789 pub const fn observed_at(&self) -> OastMonotonicTime {
791 self.observed_at
792 }
793}
794
795#[derive(Debug, Clone, PartialEq, Eq)]
797pub struct OastPollReceipt {
798 binding_id: OastBindingId,
799 correlation_id: OastCorrelationId,
800 poll_ordinal: u16,
801 completed_at: OastMonotonicTime,
802 event_receipts: Vec<OastEventReceipt>,
803 accepted_events: u16,
804 duplicate_events: u16,
805 remaining_polls: u16,
806}
807
808impl OastPollReceipt {
809 pub fn binding_id(&self) -> &OastBindingId {
811 &self.binding_id
812 }
813
814 pub fn correlation_id(&self) -> &OastCorrelationId {
816 &self.correlation_id
817 }
818
819 pub const fn poll_ordinal(&self) -> u16 {
821 self.poll_ordinal
822 }
823
824 pub const fn completed_at(&self) -> OastMonotonicTime {
826 self.completed_at
827 }
828
829 pub fn event_receipts(&self) -> &[OastEventReceipt] {
831 &self.event_receipts
832 }
833
834 pub const fn accepted_events(&self) -> u16 {
836 self.accepted_events
837 }
838
839 pub const fn duplicate_events(&self) -> u16 {
841 self.duplicate_events
842 }
843
844 pub const fn remaining_polls(&self) -> u16 {
846 self.remaining_polls
847 }
848}
849
850#[derive(Debug, Clone, PartialEq, Eq)]
852pub struct OastTerminalReceipt {
853 binding_id: OastBindingId,
854 correlation_id: OastCorrelationId,
855 state: OastCorrelationState,
856 terminal_at: OastMonotonicTime,
857}
858
859impl OastTerminalReceipt {
860 pub fn binding_id(&self) -> &OastBindingId {
862 &self.binding_id
863 }
864
865 pub fn correlation_id(&self) -> &OastCorrelationId {
867 &self.correlation_id
868 }
869
870 pub const fn state(&self) -> OastCorrelationState {
872 self.state
873 }
874
875 pub const fn terminal_at(&self) -> OastMonotonicTime {
877 self.terminal_at
878 }
879}
880
881pub struct OastCorrelationAuthority {
887 epoch: OastAuthorityEpoch,
888 assessment_id: OastAssessmentId,
889 limits: OastAuthorityLimits,
890 registered: u16,
891 token_fingerprints: BTreeSet<[u8; 32]>,
892}
893
894impl fmt::Debug for OastCorrelationAuthority {
895 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
896 formatter
897 .debug_struct("OastCorrelationAuthority")
898 .field("epoch", &self.epoch)
899 .field("assessment_id", &self.assessment_id)
900 .field("limits", &self.limits)
901 .field("registered", &self.registered)
902 .field("reserved_tokens", &self.token_fingerprints.len())
903 .finish()
904 }
905}
906
907impl OastCorrelationAuthority {
908 pub fn new(
910 epoch: OastAuthorityEpoch,
911 assessment_id: OastAssessmentId,
912 limits: OastAuthorityLimits,
913 ) -> Self {
914 Self {
915 epoch,
916 assessment_id,
917 limits,
918 registered: 0,
919 token_fingerprints: BTreeSet::new(),
920 }
921 }
922
923 pub fn epoch(&self) -> &OastAuthorityEpoch {
925 &self.epoch
926 }
927
928 pub fn assessment_id(&self) -> &OastAssessmentId {
930 &self.assessment_id
931 }
932
933 pub const fn limits(&self) -> OastAuthorityLimits {
935 self.limits
936 }
937
938 pub const fn registered(&self) -> u16 {
940 self.registered
941 }
942
943 pub fn register(
945 &mut self,
946 verification_case: VerificationCase,
947 token: OastCorrelationToken,
948 allowed_protocols: OastProtocolSet,
949 issued_at: OastMonotonicTime,
950 lifetime: OastLifetime,
951 poll_budget: OastPollBudget,
952 ) -> Result<(OastCorrelation, OastRegistrationReceipt), OastError> {
953 validate_verification_binding(&verification_case)?;
954 if self.registered >= self.limits.max_registrations {
955 return Err(OastError::RegistrationCapacityExhausted);
956 }
957 if lifetime.0 > self.limits.max_lifetime_millis {
958 return Err(OastError::LifetimeGrantExceeded {
959 requested: lifetime.0,
960 maximum: self.limits.max_lifetime_millis,
961 });
962 }
963 if poll_budget.0 > self.limits.max_polls_per_registration {
964 return Err(OastError::PollGrantExceeded {
965 requested: poll_budget.0,
966 maximum: self.limits.max_polls_per_registration,
967 });
968 }
969 let expires_at = OastMonotonicTime(
970 issued_at
971 .0
972 .checked_add(lifetime.0)
973 .ok_or(OastError::ExpiryOverflow)?,
974 );
975 let token_fingerprint = token_fingerprint(&token);
976 if self.token_fingerprints.contains(&token_fingerprint) {
977 return Err(OastError::TokenAlreadyRegistered);
978 }
979
980 let assessment_id = self.assessment_id.clone();
981 let binding_id = binding_id(&BindingMaterial {
982 epoch: &self.epoch,
983 limits: self.limits,
984 assessment_id: &assessment_id,
985 verification_case: &verification_case,
986 protocols: allowed_protocols,
987 issued_at,
988 expires_at,
989 poll_budget,
990 });
991 let correlation_id = correlation_id(&token, &binding_id);
992 let receipt = OastRegistrationReceipt {
993 binding_id: binding_id.clone(),
994 correlation_id: correlation_id.clone(),
995 issued_at,
996 expires_at,
997 poll_limit: poll_budget.0,
998 allowed_protocols,
999 };
1000 self.token_fingerprints.insert(token_fingerprint);
1001 self.registered += 1;
1002 Ok((
1003 OastCorrelation {
1004 binding_id,
1005 correlation_id,
1006 assessment_id,
1007 verification_case,
1008 allowed_protocols,
1009 issued_at,
1010 expires_at,
1011 last_time: issued_at,
1012 state: OastCorrelationState::Active,
1013 terminal_at: None,
1014 poll_limit: poll_budget.0,
1015 remaining_polls: poll_budget.0,
1016 next_poll_ordinal: 1,
1017 max_events_per_poll: self.limits.max_events_per_poll,
1018 max_unique_events: self.limits.max_unique_events_per_registration,
1019 seen_events: BTreeMap::new(),
1020 accepted_events: 0,
1021 duplicate_events: 0,
1022 abandoned_polls: 0,
1023 },
1024 receipt,
1025 ))
1026 }
1027}
1028
1029pub struct OastCorrelation {
1031 binding_id: OastBindingId,
1032 correlation_id: OastCorrelationId,
1033 assessment_id: OastAssessmentId,
1034 verification_case: VerificationCase,
1035 allowed_protocols: OastProtocolSet,
1036 issued_at: OastMonotonicTime,
1037 expires_at: OastMonotonicTime,
1038 last_time: OastMonotonicTime,
1039 state: OastCorrelationState,
1040 terminal_at: Option<OastMonotonicTime>,
1041 poll_limit: u16,
1042 remaining_polls: u16,
1043 next_poll_ordinal: u16,
1044 max_events_per_poll: u16,
1045 max_unique_events: u16,
1046 seen_events: BTreeMap<OastEventKey, OastEventProtocol>,
1047 accepted_events: u16,
1048 duplicate_events: u16,
1049 abandoned_polls: u16,
1050}
1051
1052impl fmt::Debug for OastCorrelation {
1053 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1054 formatter
1055 .debug_struct("OastCorrelation")
1056 .field("binding_id", &self.binding_id)
1057 .field("correlation_id", &self.correlation_id)
1058 .field("assessment_id", &self.assessment_id)
1059 .field("verification_case", &"<redacted>")
1060 .field("allowed_protocols", &self.allowed_protocols)
1061 .field("issued_at", &self.issued_at)
1062 .field("expires_at", &self.expires_at)
1063 .field("state", &self.state)
1064 .field("poll_limit", &self.poll_limit)
1065 .field("remaining_polls", &self.remaining_polls)
1066 .field("accepted_events", &self.accepted_events)
1067 .field("duplicate_events", &self.duplicate_events)
1068 .field("abandoned_polls", &self.abandoned_polls)
1069 .finish()
1070 }
1071}
1072
1073impl OastCorrelation {
1074 #[cfg_attr(
1081 all(
1082 not(test),
1083 feature = "oast-correlation",
1084 not(feature = "oast-native-provider")
1085 ),
1086 expect(
1087 dead_code,
1088 reason = "transactional rollback is consumed only by the separately gated native provider adapter"
1089 )
1090 )]
1091 pub(crate) fn transactional_snapshot(&self) -> Self {
1092 Self {
1093 binding_id: self.binding_id.clone(),
1094 correlation_id: self.correlation_id.clone(),
1095 assessment_id: self.assessment_id.clone(),
1096 verification_case: self.verification_case.clone(),
1097 allowed_protocols: self.allowed_protocols,
1098 issued_at: self.issued_at,
1099 expires_at: self.expires_at,
1100 last_time: self.last_time,
1101 state: self.state,
1102 terminal_at: self.terminal_at,
1103 poll_limit: self.poll_limit,
1104 remaining_polls: self.remaining_polls,
1105 next_poll_ordinal: self.next_poll_ordinal,
1106 max_events_per_poll: self.max_events_per_poll,
1107 max_unique_events: self.max_unique_events,
1108 seen_events: self.seen_events.clone(),
1109 accepted_events: self.accepted_events,
1110 duplicate_events: self.duplicate_events,
1111 abandoned_polls: self.abandoned_polls,
1112 }
1113 }
1114
1115 pub fn binding_id(&self) -> &OastBindingId {
1117 &self.binding_id
1118 }
1119
1120 pub fn correlation_id(&self) -> &OastCorrelationId {
1122 &self.correlation_id
1123 }
1124
1125 pub fn assessment_id(&self) -> &OastAssessmentId {
1127 &self.assessment_id
1128 }
1129
1130 pub fn verification_case(&self) -> &VerificationCase {
1132 &self.verification_case
1133 }
1134
1135 pub const fn allowed_protocols(&self) -> OastProtocolSet {
1137 self.allowed_protocols
1138 }
1139
1140 pub const fn issued_at(&self) -> OastMonotonicTime {
1142 self.issued_at
1143 }
1144
1145 pub const fn expires_at(&self) -> OastMonotonicTime {
1147 self.expires_at
1148 }
1149
1150 pub const fn state(&self) -> OastCorrelationState {
1152 self.state
1153 }
1154
1155 pub fn terminal_receipt(&self) -> Option<OastTerminalReceipt> {
1157 self.terminal_at.map(|terminal_at| OastTerminalReceipt {
1158 binding_id: self.binding_id.clone(),
1159 correlation_id: self.correlation_id.clone(),
1160 state: self.state,
1161 terminal_at,
1162 })
1163 }
1164
1165 pub const fn poll_limit(&self) -> u16 {
1167 self.poll_limit
1168 }
1169
1170 pub const fn remaining_polls(&self) -> u16 {
1172 self.remaining_polls
1173 }
1174
1175 pub fn unique_events(&self) -> usize {
1177 self.seen_events.len()
1178 }
1179
1180 pub const fn accepted_events(&self) -> u16 {
1182 self.accepted_events
1183 }
1184
1185 pub const fn duplicate_events(&self) -> u16 {
1187 self.duplicate_events
1188 }
1189
1190 pub const fn abandoned_polls(&self) -> u16 {
1192 self.abandoned_polls
1193 }
1194
1195 pub fn cancel(
1197 &mut self,
1198 assessment_id: &OastAssessmentId,
1199 verification_case: &VerificationCase,
1200 now: OastMonotonicTime,
1201 ) -> Result<OastTerminalReceipt, OastError> {
1202 self.ensure_binding(assessment_id, verification_case)?;
1203 self.ensure_active_at(now)?;
1204 self.last_time = now;
1205 self.state = OastCorrelationState::Cancelled;
1206 self.terminal_at = Some(now);
1207 Ok(OastTerminalReceipt {
1208 binding_id: self.binding_id.clone(),
1209 correlation_id: self.correlation_id.clone(),
1210 state: OastCorrelationState::Cancelled,
1211 terminal_at: now,
1212 })
1213 }
1214
1215 pub fn begin_poll<'a>(
1220 &'a mut self,
1221 assessment_id: &OastAssessmentId,
1222 verification_case: &VerificationCase,
1223 now: OastMonotonicTime,
1224 ) -> Result<OastPollPermit<'a>, OastError> {
1225 self.ensure_binding(assessment_id, verification_case)?;
1226 self.ensure_active_at(now)?;
1227 if self.remaining_polls == 0 {
1228 return Err(OastError::PollBudgetExhausted);
1229 }
1230 let poll_ordinal = self.next_poll_ordinal;
1231 self.next_poll_ordinal += 1;
1232 self.remaining_polls -= 1;
1233 self.last_time = now;
1234 Ok(OastPollPermit {
1235 correlation: self,
1236 poll_ordinal,
1237 latest_observed_time: now,
1238 staged_events: Vec::new(),
1239 failure: None,
1240 committed: false,
1241 })
1242 }
1243
1244 fn ensure_binding(
1245 &self,
1246 assessment_id: &OastAssessmentId,
1247 verification_case: &VerificationCase,
1248 ) -> Result<(), OastError> {
1249 if assessment_id != &self.assessment_id || verification_case != &self.verification_case {
1250 return Err(OastError::BindingMismatch);
1251 }
1252 Ok(())
1253 }
1254
1255 fn ensure_active_at(&mut self, now: OastMonotonicTime) -> Result<(), OastError> {
1256 match self.state {
1257 OastCorrelationState::Cancelled => return Err(OastError::Cancelled),
1258 OastCorrelationState::Expired => {
1259 return Err(OastError::Expired {
1260 expires_at: self.expires_at,
1261 });
1262 },
1263 OastCorrelationState::Active => {},
1264 }
1265 if now < self.last_time {
1266 return Err(OastError::ClockRegressed {
1267 previous: self.last_time,
1268 current: now,
1269 });
1270 }
1271 if now >= self.expires_at {
1272 self.state = OastCorrelationState::Expired;
1273 self.terminal_at = Some(self.expires_at);
1274 return Err(OastError::Expired {
1275 expires_at: self.expires_at,
1276 });
1277 }
1278 Ok(())
1279 }
1280}
1281
1282#[derive(Debug)]
1283struct StagedEvent {
1284 event: OastEvent,
1285 observed_at: OastMonotonicTime,
1286}
1287
1288pub struct OastPollPermit<'a> {
1293 correlation: &'a mut OastCorrelation,
1294 poll_ordinal: u16,
1295 latest_observed_time: OastMonotonicTime,
1296 staged_events: Vec<StagedEvent>,
1297 failure: Option<OastError>,
1298 committed: bool,
1299}
1300
1301impl fmt::Debug for OastPollPermit<'_> {
1302 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1303 formatter
1304 .debug_struct("OastPollPermit")
1305 .field("binding_id", &self.correlation.binding_id)
1306 .field("correlation_id", &self.correlation.correlation_id)
1307 .field("poll_ordinal", &self.poll_ordinal)
1308 .field("staged_events", &self.staged_events.len())
1309 .finish()
1310 }
1311}
1312
1313impl OastPollPermit<'_> {
1314 pub const fn poll_ordinal(&self) -> u16 {
1316 self.poll_ordinal
1317 }
1318
1319 pub const fn remaining_polls(&self) -> u16 {
1321 self.correlation.remaining_polls
1322 }
1323
1324 pub fn staged_events(&self) -> usize {
1326 self.staged_events.len()
1327 }
1328
1329 pub fn stage_event(
1331 &mut self,
1332 correlation_id: &OastCorrelationId,
1333 event: OastEvent,
1334 observed_at: OastMonotonicTime,
1335 ) -> Result<(), OastError> {
1336 if let Some(error) = &self.failure {
1337 return Err(error.clone());
1338 }
1339 if correlation_id != &self.correlation.correlation_id {
1340 return self.reject(OastError::CorrelationMismatch);
1341 }
1342 let protocol = event.protocol();
1343 if !self.correlation.allowed_protocols.allows(protocol) {
1344 return self.reject(OastError::ProtocolNotAllowed { protocol });
1345 }
1346 if self.staged_events.len() >= usize::from(self.correlation.max_events_per_poll) {
1347 return self.reject(OastError::PollEventLimitExceeded {
1348 maximum: self.correlation.max_events_per_poll,
1349 });
1350 }
1351 if let Err(error) = self.correlation.ensure_active_at(observed_at) {
1352 return self.reject(error);
1353 }
1354 if observed_at > self.latest_observed_time {
1355 self.latest_observed_time = observed_at;
1356 }
1357 self.staged_events.push(StagedEvent { event, observed_at });
1358 Ok(())
1359 }
1360
1361 pub fn finish(mut self, completed_at: OastMonotonicTime) -> Result<OastPollReceipt, OastError> {
1363 self.ensure_completion_time(completed_at)?;
1364 if let Some(error) = self.failure.take() {
1365 return Err(error);
1366 }
1367 self.staged_events.sort_by(|left, right| {
1368 left.event
1369 .key()
1370 .cmp(right.event.key())
1371 .then_with(|| left.event.protocol().cmp(&right.event.protocol()))
1372 .then_with(|| left.observed_at.cmp(&right.observed_at))
1373 });
1374
1375 let mut batch_protocols = BTreeMap::<OastEventKey, OastEventProtocol>::new();
1376 let mut receipts = Vec::with_capacity(self.staged_events.len());
1377 let mut accepted_events = 0u16;
1378 let mut duplicate_events = 0u16;
1379
1380 for staged in &self.staged_events {
1381 let key = staged.event.key();
1382 let protocol = staged.event.protocol();
1383 let known = self
1384 .correlation
1385 .seen_events
1386 .get(key)
1387 .or_else(|| batch_protocols.get(key));
1388 let disposition = match known {
1389 Some(existing) if *existing == protocol => {
1390 duplicate_events += 1;
1391 OastEventDisposition::DuplicateSuppressed
1392 },
1393 Some(_) => return Err(OastError::EventKeyProtocolConflict),
1394 None => {
1395 accepted_events += 1;
1396 batch_protocols.insert(key.clone(), protocol);
1397 OastEventDisposition::Accepted
1398 },
1399 };
1400 receipts.push(OastEventReceipt {
1401 event_key: key.clone(),
1402 protocol,
1403 disposition,
1404 observed_at: staged.observed_at,
1405 });
1406 }
1407
1408 if self.correlation.seen_events.len() + batch_protocols.len()
1409 > usize::from(self.correlation.max_unique_events)
1410 {
1411 return Err(OastError::UniqueEventLimitExceeded {
1412 maximum: self.correlation.max_unique_events,
1413 });
1414 }
1415
1416 for (key, protocol) in batch_protocols {
1417 self.correlation.seen_events.insert(key, protocol);
1418 }
1419 self.correlation.accepted_events += accepted_events;
1420 self.correlation.duplicate_events += duplicate_events;
1421 self.correlation.last_time = completed_at;
1422 self.committed = true;
1423
1424 Ok(OastPollReceipt {
1425 binding_id: self.correlation.binding_id.clone(),
1426 correlation_id: self.correlation.correlation_id.clone(),
1427 poll_ordinal: self.poll_ordinal,
1428 completed_at,
1429 event_receipts: receipts,
1430 accepted_events,
1431 duplicate_events,
1432 remaining_polls: self.correlation.remaining_polls,
1433 })
1434 }
1435
1436 fn ensure_completion_time(&mut self, completed_at: OastMonotonicTime) -> Result<(), OastError> {
1437 self.correlation.ensure_active_at(completed_at)?;
1438 if completed_at < self.latest_observed_time {
1439 return Err(OastError::ClockRegressed {
1440 previous: self.latest_observed_time,
1441 current: completed_at,
1442 });
1443 }
1444 Ok(())
1445 }
1446
1447 fn reject<T>(&mut self, error: OastError) -> Result<T, OastError> {
1448 self.failure = Some(error.clone());
1449 Err(error)
1450 }
1451}
1452
1453impl Drop for OastPollPermit<'_> {
1454 fn drop(&mut self) {
1455 if !self.committed {
1456 self.correlation.abandoned_polls += 1;
1457 }
1458 }
1459}
1460
1461fn reject_zero(bytes: &[u8; 32]) -> Result<(), OastError> {
1462 if bytes.iter().all(|byte| *byte == 0) {
1463 return Err(OastError::ZeroOpaqueId);
1464 }
1465 Ok(())
1466}
1467
1468fn validate_verification_binding(verification_case: &VerificationCase) -> Result<(), OastError> {
1469 let components = [
1470 verification_case.id(),
1471 verification_case.subject().as_str(),
1472 verification_case.action_id(),
1473 verification_case.hypothesis_id(),
1474 ];
1475 if components
1476 .iter()
1477 .any(|component| component.len() > MAX_VERIFICATION_BINDING_COMPONENT_BYTES)
1478 {
1479 return Err(OastError::VerificationBindingTooLarge);
1480 }
1481 Ok(())
1482}
1483
1484fn token_fingerprint(token: &OastCorrelationToken) -> [u8; 32] {
1485 let mut hasher = Sha256::new();
1486 hasher.update(TOKEN_REUSE_DOMAIN);
1487 hash_field(&mut hasher, &token.secret_bytes);
1488 hasher.finalize().into()
1489}
1490
1491struct BindingMaterial<'a> {
1492 epoch: &'a OastAuthorityEpoch,
1493 limits: OastAuthorityLimits,
1494 assessment_id: &'a OastAssessmentId,
1495 verification_case: &'a VerificationCase,
1496 protocols: OastProtocolSet,
1497 issued_at: OastMonotonicTime,
1498 expires_at: OastMonotonicTime,
1499 poll_budget: OastPollBudget,
1500}
1501
1502fn binding_id(material: &BindingMaterial<'_>) -> OastBindingId {
1503 let mut hasher = Sha256::new();
1504 hasher.update(BINDING_ID_DOMAIN);
1505 hash_field(&mut hasher, &material.epoch.0);
1506 hash_field(&mut hasher, material.assessment_id.0.as_bytes());
1507 hash_verification_case(&mut hasher, material.verification_case);
1508 hasher.update([material.protocols.0]);
1509 hasher.update(material.issued_at.0.to_be_bytes());
1510 hasher.update(material.expires_at.0.to_be_bytes());
1511 hasher.update(material.poll_budget.0.to_be_bytes());
1512 hasher.update(material.limits.max_registrations.to_be_bytes());
1513 hasher.update(material.limits.max_polls_per_registration.to_be_bytes());
1514 hasher.update(material.limits.max_events_per_poll.to_be_bytes());
1515 hasher.update(
1516 material
1517 .limits
1518 .max_unique_events_per_registration
1519 .to_be_bytes(),
1520 );
1521 hasher.update(material.limits.max_lifetime_millis.to_be_bytes());
1522 OastBindingId(hasher.finalize().into())
1523}
1524
1525fn correlation_id(token: &OastCorrelationToken, binding_id: &OastBindingId) -> OastCorrelationId {
1526 let mut hasher = Sha256::new();
1527 hasher.update(CORRELATION_ID_DOMAIN);
1528 hash_field(&mut hasher, &token.secret_bytes);
1529 hash_field(&mut hasher, &binding_id.0);
1530 OastCorrelationId(hasher.finalize().into())
1531}
1532
1533fn hash_verification_case(hasher: &mut Sha256, verification_case: &VerificationCase) {
1534 hash_field(hasher, verification_case.id().as_bytes());
1535 hash_field(hasher, verification_case.subject().as_str().as_bytes());
1536 hash_field(hasher, verification_case.action_id().as_bytes());
1537 hash_field(hasher, verification_case.hypothesis_id().as_bytes());
1538 hasher.update([u8::from(verification_case.applies_hypothesis_transition())]);
1539 match verification_case.payload_strategy() {
1540 Some(strategy) => {
1541 hasher.update([1]);
1542 hash_field(hasher, strategy.id().as_bytes());
1543 hasher.update(strategy.revision().to_be_bytes());
1544 },
1545 None => hasher.update([0]),
1546 }
1547}
1548
1549fn hash_field(hasher: &mut Sha256, bytes: &[u8]) {
1550 hasher.update((bytes.len() as u128).to_be_bytes());
1551 hasher.update(bytes);
1552}
1553
1554#[cfg(all(test, feature = "oast-correlation"))]
1555mod tests {
1556 use super::*;
1557 use crate::payload_strategy::PayloadStrategyRef;
1558 use termivar_core::EntityId;
1559
1560 const SECRET: [u8; 32] = [
1561 0x91, 0x2f, 0x73, 0xa4, 0x05, 0x66, 0xb7, 0x18, 0xc9, 0x2a, 0xdb, 0x3c, 0xed, 0x4e, 0xff,
1562 0x50, 0x11, 0x82, 0x23, 0x94, 0x35, 0xa6, 0x47, 0xb8, 0x59, 0xca, 0x6b, 0xdc, 0x7d, 0xee,
1563 0x8f, 0xf0,
1564 ];
1565 const EPOCH: [u8; 32] = [
1566 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
1567 26, 27, 28, 29, 30, 31, 32,
1568 ];
1569
1570 fn assessment(value: &str) -> OastAssessmentId {
1571 OastAssessmentId::new(value).unwrap()
1572 }
1573
1574 fn subject(value: &str) -> EntityId {
1575 EntityId::new(value).unwrap()
1576 }
1577
1578 fn verification_case() -> VerificationCase {
1579 VerificationCase::new(
1580 "case:oast:1",
1581 subject("resource:oast:1"),
1582 "web.review.oast",
1583 "hypothesis:ssrf:1",
1584 )
1585 .unwrap()
1586 }
1587
1588 fn token(bytes: [u8; 32]) -> OastCorrelationToken {
1589 OastCorrelationToken::new(bytes).unwrap()
1590 }
1591
1592 fn epoch(bytes: [u8; 32]) -> OastAuthorityEpoch {
1593 OastAuthorityEpoch::new(bytes).unwrap()
1594 }
1595
1596 fn limits(
1597 registrations: u16,
1598 polls: u16,
1599 events_per_poll: u16,
1600 unique_events: u16,
1601 ) -> OastAuthorityLimits {
1602 OastAuthorityLimits::new(registrations, polls, events_per_poll, unique_events, 100).unwrap()
1603 }
1604
1605 fn protocols(dns: bool, http: bool) -> OastProtocolSet {
1606 OastProtocolSet::new(dns, http).unwrap()
1607 }
1608
1609 fn event_key(marker: u8) -> OastEventKey {
1610 let mut bytes = [0; 32];
1611 bytes[0] = marker;
1612 OastEventKey::new(bytes).unwrap()
1613 }
1614
1615 fn dns(marker: u8) -> OastEvent {
1616 OastEvent::Dns(
1617 event_key(marker),
1618 OastDnsEvent::new(OastDnsTransport::Udp, OastDnsRecordType::A),
1619 )
1620 }
1621
1622 fn http(marker: u8) -> OastEvent {
1623 OastEvent::Http(
1624 event_key(marker),
1625 OastHttpEvent::new(OastHttpScheme::Https, OastHttpMethod::Get, false),
1626 )
1627 }
1628
1629 fn register(
1630 authority: &mut OastCorrelationAuthority,
1631 secret: [u8; 32],
1632 ) -> (OastCorrelation, OastRegistrationReceipt) {
1633 authority
1634 .register(
1635 verification_case(),
1636 token(secret),
1637 protocols(true, true),
1638 OastMonotonicTime::from_millis(1_000),
1639 OastLifetime::from_millis(100).unwrap(),
1640 OastPollBudget::new(3).unwrap(),
1641 )
1642 .unwrap()
1643 }
1644
1645 fn authority() -> OastCorrelationAuthority {
1646 OastCorrelationAuthority::new(epoch(EPOCH), assessment("assessment:1"), limits(8, 4, 4, 8))
1647 }
1648
1649 #[test]
1650 fn constructors_enforce_exact_public_bounds() {
1651 assert_eq!(
1652 OastAssessmentId::new("").unwrap_err(),
1653 OastError::EmptyAssessmentId
1654 );
1655 assert_eq!(
1656 OastAssessmentId::new(" assessment:1").unwrap_err(),
1657 OastError::InvalidAssessmentId
1658 );
1659 assert_eq!(
1660 OastAssessmentId::new("x".repeat(MAX_ASSESSMENT_ID_BYTES + 1)).unwrap_err(),
1661 OastError::AssessmentIdTooLong {
1662 actual: MAX_ASSESSMENT_ID_BYTES + 1,
1663 maximum: MAX_ASSESSMENT_ID_BYTES,
1664 }
1665 );
1666 assert_eq!(
1667 OastCorrelationToken::new([0; 32]).unwrap_err(),
1668 OastError::ZeroOpaqueId
1669 );
1670 assert_eq!(
1671 OastAuthorityEpoch::new([0; 32]).unwrap_err(),
1672 OastError::ZeroOpaqueId
1673 );
1674 assert_eq!(
1675 OastEventKey::new([0; 32]).unwrap_err(),
1676 OastError::ZeroOpaqueId
1677 );
1678 assert_eq!(
1679 OastLifetime::from_millis(0).unwrap_err(),
1680 OastError::ZeroLifetime
1681 );
1682 assert_eq!(
1683 OastLifetime::from_millis(HARD_MAX_LIFETIME_MILLIS + 1).unwrap_err(),
1684 OastError::LifetimeTooLong {
1685 actual: HARD_MAX_LIFETIME_MILLIS + 1,
1686 maximum: HARD_MAX_LIFETIME_MILLIS,
1687 }
1688 );
1689 assert_eq!(
1690 OastPollBudget::new(0).unwrap_err(),
1691 OastError::ZeroPollBudget
1692 );
1693 assert_eq!(
1694 OastPollBudget::new(HARD_MAX_POLLS + 1).unwrap_err(),
1695 OastError::PollBudgetTooLarge {
1696 actual: HARD_MAX_POLLS + 1,
1697 maximum: HARD_MAX_POLLS,
1698 }
1699 );
1700 assert_eq!(
1701 OastAuthorityLimits::new(0, 1, 1, 1, 1).unwrap_err(),
1702 OastError::ZeroAuthorityLimit
1703 );
1704 assert_eq!(
1705 OastAuthorityLimits::new(HARD_MAX_REGISTRATIONS + 1, 1, 1, 1, 1).unwrap_err(),
1706 OastError::AuthorityLimitTooLarge
1707 );
1708 assert_eq!(
1709 OastProtocolSet::new(false, false).unwrap_err(),
1710 OastError::EmptyProtocolSet
1711 );
1712 assert_eq!(
1713 assessment("Assessment_1.v2:test").as_str(),
1714 "Assessment_1.v2:test"
1715 );
1716 }
1717
1718 #[test]
1719 fn public_bounds_and_receipts_expose_only_typed_state() {
1720 let authority_limits = limits(2, 2, 3, 4);
1721 assert_eq!(authority_limits.max_registrations(), 2);
1722 assert_eq!(authority_limits.max_polls_per_registration(), 2);
1723 assert_eq!(authority_limits.max_events_per_poll(), 3);
1724 assert_eq!(authority_limits.max_unique_events_per_registration(), 4);
1725 assert_eq!(authority_limits.max_lifetime_millis(), 100);
1726 assert_eq!(OastLifetime::from_millis(100).unwrap().as_millis(), 100);
1727 assert_eq!(OastPollBudget::new(2).unwrap().polls(), 2);
1728
1729 let mut authority = OastCorrelationAuthority::new(
1730 epoch(EPOCH),
1731 assessment("assessment:1"),
1732 authority_limits,
1733 );
1734 assert_eq!(authority.epoch(), &epoch(EPOCH));
1735 assert_eq!(authority.limits(), authority_limits);
1736 let (mut correlation, registration) = authority
1737 .register(
1738 verification_case(),
1739 token(SECRET),
1740 protocols(true, true),
1741 OastMonotonicTime::from_millis(1_000),
1742 OastLifetime::from_millis(100).unwrap(),
1743 OastPollBudget::new(2).unwrap(),
1744 )
1745 .unwrap();
1746 assert_eq!(registration.binding_id(), correlation.binding_id());
1747 assert_eq!(registration.correlation_id(), correlation.correlation_id());
1748 assert_eq!(registration.issued_at().as_millis(), 1_000);
1749 assert_eq!(registration.expires_at().as_millis(), 1_100);
1750 assert_eq!(registration.poll_limit(), 2);
1751 assert!(registration.allowed_protocols().allows_dns());
1752 assert!(registration.allowed_protocols().allows_http());
1753 assert_eq!(correlation.issued_at(), registration.issued_at());
1754 assert_eq!(correlation.expires_at(), registration.expires_at());
1755 assert_eq!(correlation.poll_limit(), registration.poll_limit());
1756 assert_eq!(correlation.state(), OastCorrelationState::Active);
1757 assert!(correlation.terminal_receipt().is_none());
1758
1759 let id = correlation.correlation_id().clone();
1760 let bound_assessment = correlation.assessment_id().clone();
1761 let case = correlation.verification_case().clone();
1762 let mut permit = correlation
1763 .begin_poll(
1764 &bound_assessment,
1765 &case,
1766 OastMonotonicTime::from_millis(1_001),
1767 )
1768 .unwrap();
1769 assert_eq!(permit.poll_ordinal(), 1);
1770 assert_eq!(permit.remaining_polls(), 1);
1771 assert_eq!(permit.staged_events(), 0);
1772 assert!(format!("{permit:?}").contains("staged_events: 0"));
1773 permit
1774 .stage_event(&id, http(1), OastMonotonicTime::from_millis(1_002))
1775 .unwrap();
1776 assert_eq!(permit.staged_events(), 1);
1777 let receipt = permit
1778 .finish(OastMonotonicTime::from_millis(1_003))
1779 .unwrap();
1780 assert_eq!(receipt.poll_ordinal(), 1);
1781 assert_eq!(receipt.completed_at().as_millis(), 1_003);
1782 assert_eq!(receipt.remaining_polls(), 1);
1783 let event = &receipt.event_receipts()[0];
1784 assert_eq!(event.event_key(), &event_key(1));
1785 assert_eq!(event.protocol(), OastEventProtocol::Http);
1786 assert_eq!(event.disposition(), OastEventDisposition::Accepted);
1787 assert_eq!(event.observed_at().as_millis(), 1_002);
1788
1789 let terminal = correlation
1790 .cancel(
1791 &bound_assessment,
1792 &case,
1793 OastMonotonicTime::from_millis(1_004),
1794 )
1795 .unwrap();
1796 assert_eq!(terminal.binding_id(), registration.binding_id());
1797 assert_eq!(terminal.correlation_id(), registration.correlation_id());
1798 }
1799
1800 #[test]
1801 fn every_error_has_a_value_safe_typed_display() {
1802 let errors = [
1803 (OastError::EmptyAssessmentId, "OAST assessment id is empty"),
1804 (
1805 OastError::AssessmentIdTooLong {
1806 actual: 257,
1807 maximum: 256,
1808 },
1809 "OAST assessment id length 257 exceeds 256",
1810 ),
1811 (
1812 OastError::InvalidAssessmentId,
1813 "OAST assessment id is invalid",
1814 ),
1815 (
1816 OastError::ZeroOpaqueId,
1817 "OAST opaque id must not be all zero",
1818 ),
1819 (OastError::ZeroLifetime, "OAST lifetime must be non-zero"),
1820 (
1821 OastError::LifetimeTooLong {
1822 actual: 101,
1823 maximum: 100,
1824 },
1825 "OAST lifetime 101ms exceeds 100ms",
1826 ),
1827 (
1828 OastError::ZeroPollBudget,
1829 "OAST poll budget must be non-zero",
1830 ),
1831 (
1832 OastError::PollBudgetTooLarge {
1833 actual: 2,
1834 maximum: 1,
1835 },
1836 "OAST poll budget 2 exceeds 1",
1837 ),
1838 (
1839 OastError::ZeroAuthorityLimit,
1840 "OAST authority limits must be non-zero",
1841 ),
1842 (
1843 OastError::AuthorityLimitTooLarge,
1844 "OAST authority limit exceeds a hard foundation bound",
1845 ),
1846 (
1847 OastError::EmptyProtocolSet,
1848 "OAST protocol set must not be empty",
1849 ),
1850 (
1851 OastError::RegistrationCapacityExhausted,
1852 "OAST registration capacity is exhausted",
1853 ),
1854 (
1855 OastError::TokenAlreadyRegistered,
1856 "OAST token was already registered in this authority epoch",
1857 ),
1858 (
1859 OastError::VerificationBindingTooLarge,
1860 "OAST verification binding exceeds its component bound",
1861 ),
1862 (
1863 OastError::LifetimeGrantExceeded {
1864 requested: 101,
1865 maximum: 100,
1866 },
1867 "OAST lifetime 101ms exceeds grant 100ms",
1868 ),
1869 (
1870 OastError::PollGrantExceeded {
1871 requested: 2,
1872 maximum: 1,
1873 },
1874 "OAST poll count 2 exceeds grant 1",
1875 ),
1876 (
1877 OastError::ExpiryOverflow,
1878 "OAST expiry overflowed monotonic time",
1879 ),
1880 (OastError::BindingMismatch, "OAST binding does not match"),
1881 (
1882 OastError::CorrelationMismatch,
1883 "OAST correlation does not match",
1884 ),
1885 (
1886 OastError::ProtocolNotAllowed {
1887 protocol: OastEventProtocol::Http,
1888 },
1889 "OAST protocol Http is not allowed",
1890 ),
1891 (
1892 OastError::ClockRegressed {
1893 previous: OastMonotonicTime::from_millis(2),
1894 current: OastMonotonicTime::from_millis(1),
1895 },
1896 "OAST time regressed from 2ms to 1ms",
1897 ),
1898 (
1899 OastError::Expired {
1900 expires_at: OastMonotonicTime::from_millis(9),
1901 },
1902 "OAST correlation expired at 9ms",
1903 ),
1904 (OastError::Cancelled, "OAST correlation is cancelled"),
1905 (
1906 OastError::PollBudgetExhausted,
1907 "OAST poll budget is exhausted",
1908 ),
1909 (
1910 OastError::PollEventLimitExceeded { maximum: 3 },
1911 "OAST poll exceeds its 3-event bound",
1912 ),
1913 (
1914 OastError::UniqueEventLimitExceeded { maximum: 4 },
1915 "OAST registration exceeds its 4-event bound",
1916 ),
1917 (
1918 OastError::EventKeyProtocolConflict,
1919 "OAST event key has conflicting protocol families",
1920 ),
1921 ];
1922 for (error, expected) in errors {
1923 assert_eq!(error.to_string(), expected);
1924 let as_error: &dyn Error = &error;
1925 assert!(as_error.source().is_none());
1926 }
1927 }
1928
1929 #[test]
1930 fn expiry_overflow_and_exhausted_poll_budget_fail_closed() {
1931 let mut overflow_authority = OastCorrelationAuthority::new(
1932 epoch(EPOCH),
1933 assessment("assessment:1"),
1934 limits(2, 1, 1, 1),
1935 );
1936 assert_eq!(
1937 overflow_authority
1938 .register(
1939 verification_case(),
1940 token(SECRET),
1941 protocols(true, false),
1942 OastMonotonicTime::from_millis(u64::MAX),
1943 OastLifetime::from_millis(1).unwrap(),
1944 OastPollBudget::new(1).unwrap(),
1945 )
1946 .unwrap_err(),
1947 OastError::ExpiryOverflow
1948 );
1949 assert_eq!(overflow_authority.registered(), 0);
1950
1951 let (mut correlation, _) = overflow_authority
1952 .register(
1953 verification_case(),
1954 token(SECRET),
1955 protocols(true, false),
1956 OastMonotonicTime::from_millis(1_000),
1957 OastLifetime::from_millis(100).unwrap(),
1958 OastPollBudget::new(1).unwrap(),
1959 )
1960 .unwrap();
1961 let bound_assessment = correlation.assessment_id().clone();
1962 let case = correlation.verification_case().clone();
1963 correlation
1964 .begin_poll(
1965 &bound_assessment,
1966 &case,
1967 OastMonotonicTime::from_millis(1_001),
1968 )
1969 .unwrap()
1970 .finish(OastMonotonicTime::from_millis(1_002))
1971 .unwrap();
1972 assert_eq!(correlation.remaining_polls(), 0);
1973 assert_eq!(
1974 correlation
1975 .begin_poll(
1976 &bound_assessment,
1977 &case,
1978 OastMonotonicTime::from_millis(1_003),
1979 )
1980 .unwrap_err(),
1981 OastError::PollBudgetExhausted
1982 );
1983 }
1984
1985 #[test]
1986 fn secret_and_opaque_debug_output_is_redacted() {
1987 let mut secret = token(SECRET);
1988 assert_eq!(format!("{secret:?}"), "OastCorrelationToken(<redacted>)");
1989 secret.erase();
1990 assert_eq!(secret.secret_bytes, [0; 32]);
1991 assert!(std::mem::needs_drop::<OastCorrelationToken>());
1992
1993 let mut authority = authority();
1994 let (correlation, receipt) = register(&mut authority, SECRET);
1995 let rendered = format!(
1996 "{authority:?} {correlation:?} {receipt:?} {:?} {:?} {:?}",
1997 epoch(EPOCH),
1998 receipt.binding_id(),
1999 event_key(9)
2000 );
2001 for raw in [
2002 "assessment:1",
2003 "case:oast:1",
2004 "resource:oast:1",
2005 "hypothesis:ssrf:1",
2006 "145, 47, 115",
2007 ] {
2008 assert!(!rendered.contains(raw));
2009 }
2010 assert!(rendered.matches("<redacted>").count() >= 5);
2011 }
2012
2013 #[test]
2014 fn authority_rejects_reuse_and_capacity_without_forgetting_terminal_tokens() {
2015 let mut authority = OastCorrelationAuthority::new(
2016 epoch(EPOCH),
2017 assessment("assessment:1"),
2018 limits(2, 3, 3, 3),
2019 );
2020 let (mut first, _) = register(&mut authority, SECRET);
2021 let bound_assessment = first.assessment_id().clone();
2022 let case = first.verification_case().clone();
2023 let cancelled = first
2024 .cancel(
2025 &bound_assessment,
2026 &case,
2027 OastMonotonicTime::from_millis(1_001),
2028 )
2029 .unwrap();
2030 assert_eq!(cancelled.state(), OastCorrelationState::Cancelled);
2031 assert_eq!(
2032 authority
2033 .register(
2034 verification_case(),
2035 token(SECRET),
2036 protocols(true, false),
2037 OastMonotonicTime::from_millis(2_000),
2038 OastLifetime::from_millis(50).unwrap(),
2039 OastPollBudget::new(1).unwrap(),
2040 )
2041 .unwrap_err(),
2042 OastError::TokenAlreadyRegistered
2043 );
2044
2045 let mut second_secret = SECRET;
2046 second_secret[0] ^= 1;
2047 register(&mut authority, second_secret);
2048 let mut third_secret = SECRET;
2049 third_secret[1] ^= 1;
2050 assert_eq!(
2051 authority
2052 .register(
2053 verification_case(),
2054 token(third_secret),
2055 protocols(true, true),
2056 OastMonotonicTime::from_millis(3_000),
2057 OastLifetime::from_millis(50).unwrap(),
2058 OastPollBudget::new(1).unwrap(),
2059 )
2060 .unwrap_err(),
2061 OastError::RegistrationCapacityExhausted
2062 );
2063 assert_eq!(authority.registered(), 2);
2064 }
2065
2066 #[test]
2067 fn registration_enforces_authority_grants_before_mutation() {
2068 let mut authority = OastCorrelationAuthority::new(
2069 epoch(EPOCH),
2070 assessment("assessment:1"),
2071 limits(2, 1, 1, 1),
2072 );
2073 assert_eq!(authority.assessment_id(), &assessment("assessment:1"));
2074 assert_eq!(
2075 authority
2076 .register(
2077 verification_case(),
2078 token(SECRET),
2079 protocols(true, false),
2080 OastMonotonicTime::from_millis(0),
2081 OastLifetime::from_millis(101).unwrap(),
2082 OastPollBudget::new(1).unwrap(),
2083 )
2084 .unwrap_err(),
2085 OastError::LifetimeGrantExceeded {
2086 requested: 101,
2087 maximum: 100,
2088 }
2089 );
2090 assert_eq!(
2091 authority
2092 .register(
2093 verification_case(),
2094 token(SECRET),
2095 protocols(true, false),
2096 OastMonotonicTime::from_millis(0),
2097 OastLifetime::from_millis(100).unwrap(),
2098 OastPollBudget::new(2).unwrap(),
2099 )
2100 .unwrap_err(),
2101 OastError::PollGrantExceeded {
2102 requested: 2,
2103 maximum: 1,
2104 }
2105 );
2106 assert_eq!(authority.registered(), 0);
2107 authority
2108 .register(
2109 verification_case(),
2110 token(SECRET),
2111 protocols(true, false),
2112 OastMonotonicTime::from_millis(0),
2113 OastLifetime::from_millis(100).unwrap(),
2114 OastPollBudget::new(1).unwrap(),
2115 )
2116 .unwrap();
2117 assert_eq!(authority.registered(), 1);
2118 }
2119
2120 #[test]
2121 fn registration_bounds_every_verification_identity_before_hashing() {
2122 let oversized = "x".repeat(MAX_VERIFICATION_BINDING_COMPONENT_BYTES + 1);
2123 let cases = [
2124 VerificationCase::new(
2125 oversized.clone(),
2126 subject("resource:oast:1"),
2127 "action:oast:1",
2128 "hypothesis:ssrf:1",
2129 )
2130 .unwrap(),
2131 VerificationCase::new(
2132 "case:oast:1",
2133 subject(&oversized),
2134 "action:oast:1",
2135 "hypothesis:ssrf:1",
2136 )
2137 .unwrap(),
2138 VerificationCase::new(
2139 "case:oast:1",
2140 subject("resource:oast:1"),
2141 oversized.clone(),
2142 "hypothesis:ssrf:1",
2143 )
2144 .unwrap(),
2145 VerificationCase::new(
2146 "case:oast:1",
2147 subject("resource:oast:1"),
2148 "action:oast:1",
2149 oversized,
2150 )
2151 .unwrap(),
2152 ];
2153 let mut authority = authority();
2154 for case in cases {
2155 assert_eq!(
2156 authority
2157 .register(
2158 case,
2159 token(SECRET),
2160 protocols(true, true),
2161 OastMonotonicTime::from_millis(1_000),
2162 OastLifetime::from_millis(100).unwrap(),
2163 OastPollBudget::new(1).unwrap(),
2164 )
2165 .unwrap_err(),
2166 OastError::VerificationBindingTooLarge
2167 );
2168 assert_eq!(authority.registered(), 0);
2169 }
2170
2171 let maximum = "x".repeat(MAX_VERIFICATION_BINDING_COMPONENT_BYTES);
2172 let maximum_case =
2173 VerificationCase::new(maximum.clone(), subject(&maximum), maximum.clone(), maximum)
2174 .unwrap();
2175 authority
2176 .register(
2177 maximum_case,
2178 token(SECRET),
2179 protocols(true, true),
2180 OastMonotonicTime::from_millis(1_000),
2181 OastLifetime::from_millis(100).unwrap(),
2182 OastPollBudget::new(1).unwrap(),
2183 )
2184 .unwrap();
2185 assert_eq!(authority.registered(), 1);
2186 }
2187
2188 #[test]
2189 fn binding_and_correlation_ids_cover_every_semantic_dimension() {
2190 #[derive(Clone)]
2191 struct IdInputs {
2192 epoch_bytes: [u8; 32],
2193 authority_limits: OastAuthorityLimits,
2194 assessment_id: OastAssessmentId,
2195 case: VerificationCase,
2196 secret: [u8; 32],
2197 allowed_protocols: OastProtocolSet,
2198 issued_at: u64,
2199 lifetime: u64,
2200 polls: u16,
2201 }
2202
2203 fn ids(inputs: IdInputs) -> (OastBindingId, OastCorrelationId) {
2204 let mut authority = OastCorrelationAuthority::new(
2205 epoch(inputs.epoch_bytes),
2206 inputs.assessment_id.clone(),
2207 inputs.authority_limits,
2208 );
2209 let receipt = authority
2210 .register(
2211 inputs.case,
2212 token(inputs.secret),
2213 inputs.allowed_protocols,
2214 OastMonotonicTime::from_millis(inputs.issued_at),
2215 OastLifetime::from_millis(inputs.lifetime).unwrap(),
2216 OastPollBudget::new(inputs.polls).unwrap(),
2217 )
2218 .unwrap()
2219 .1;
2220 (receipt.binding_id, receipt.correlation_id)
2221 }
2222
2223 let base_limits = limits(8, 4, 4, 8);
2224 let inputs = IdInputs {
2225 epoch_bytes: EPOCH,
2226 authority_limits: base_limits,
2227 assessment_id: assessment("assessment:1"),
2228 case: verification_case(),
2229 secret: SECRET,
2230 allowed_protocols: protocols(true, true),
2231 issued_at: 1_000,
2232 lifetime: 100,
2233 polls: 3,
2234 };
2235 let baseline = ids(inputs.clone());
2236 assert_eq!(baseline, ids(inputs.clone()));
2237
2238 let mut changed_epoch = EPOCH;
2239 changed_epoch[0] ^= 1;
2240 let changed_cases = [
2241 VerificationCase::new(
2242 "case:oast:2",
2243 subject("resource:oast:1"),
2244 "web.review.oast",
2245 "hypothesis:ssrf:1",
2246 )
2247 .unwrap(),
2248 VerificationCase::new(
2249 "case:oast:1",
2250 subject("resource:oast:2"),
2251 "web.review.oast",
2252 "hypothesis:ssrf:1",
2253 )
2254 .unwrap(),
2255 VerificationCase::new(
2256 "case:oast:1",
2257 subject("resource:oast:1"),
2258 "web.review.other",
2259 "hypothesis:ssrf:1",
2260 )
2261 .unwrap(),
2262 VerificationCase::new(
2263 "case:oast:1",
2264 subject("resource:oast:1"),
2265 "web.review.oast",
2266 "hypothesis:ssrf:2",
2267 )
2268 .unwrap(),
2269 verification_case().without_hypothesis_transition(),
2270 verification_case()
2271 .with_payload_strategy(Some(PayloadStrategyRef::new("strategy:oast", 1).unwrap())),
2272 ];
2273 for case in changed_cases {
2274 let mut changed = inputs.clone();
2275 changed.case = case;
2276 assert_ne!(baseline.0, ids(changed).0);
2277 }
2278
2279 let variations = [
2280 IdInputs {
2281 epoch_bytes: changed_epoch,
2282 ..inputs.clone()
2283 },
2284 IdInputs {
2285 assessment_id: assessment("assessment:2"),
2286 ..inputs.clone()
2287 },
2288 IdInputs {
2289 allowed_protocols: protocols(true, false),
2290 ..inputs.clone()
2291 },
2292 IdInputs {
2293 issued_at: 1_001,
2294 lifetime: 99,
2295 ..inputs.clone()
2296 },
2297 IdInputs {
2298 authority_limits: limits(9, 4, 4, 8),
2299 ..inputs.clone()
2300 },
2301 IdInputs {
2302 polls: 2,
2303 ..inputs.clone()
2304 },
2305 ];
2306 for variation in variations.map(ids) {
2307 assert_ne!(baseline.0, variation.0);
2308 assert_ne!(baseline.1, variation.1);
2309 }
2310
2311 let mut changed_secret = SECRET;
2312 changed_secret[31] ^= 1;
2313 let secret_variation = ids(IdInputs {
2314 secret: changed_secret,
2315 ..inputs
2316 });
2317 assert_eq!(baseline.0, secret_variation.0);
2318 assert_ne!(baseline.1, secret_variation.1);
2319 }
2320
2321 #[test]
2322 fn v1_identity_algorithms_match_fixed_golden_vectors() {
2323 assert_eq!(
2324 token_fingerprint(&token(SECRET)),
2325 [
2326 0x16, 0x55, 0x21, 0x51, 0xf8, 0xe5, 0xb0, 0xa6, 0x85, 0x0c, 0x39, 0x32, 0x1a, 0xbc,
2327 0x13, 0xa2, 0xb6, 0x03, 0xbe, 0x3b, 0x35, 0x3d, 0xc1, 0xf5, 0x88, 0x61, 0x03, 0xaf,
2328 0xc5, 0x57, 0xb0, 0xc9,
2329 ]
2330 );
2331
2332 let mut authority = authority();
2333 let (_, receipt) = register(&mut authority, SECRET);
2334 assert_eq!(
2335 receipt.binding_id.0,
2336 [
2337 0x8e, 0xdd, 0xd8, 0x7b, 0x7b, 0x81, 0x66, 0x7c, 0xf3, 0xff, 0x38, 0x8b, 0x13, 0x63,
2338 0x5e, 0x7e, 0x61, 0xc4, 0xc6, 0xed, 0x3b, 0x89, 0xfb, 0x0a, 0x50, 0x72, 0x32, 0x4d,
2339 0x6e, 0x05, 0x03, 0xb3,
2340 ]
2341 );
2342 assert_eq!(
2343 receipt.correlation_id.0,
2344 [
2345 0x6e, 0x91, 0x58, 0x98, 0x5c, 0x84, 0x6f, 0xf0, 0xf0, 0x98, 0xf9, 0x37, 0xba, 0xe3,
2346 0x7e, 0x00, 0x5d, 0x1a, 0x6e, 0x0f, 0xf4, 0xd9, 0x33, 0x00, 0xa0, 0x11, 0x72, 0xd1,
2347 0x1b, 0xf0, 0x92, 0xc7,
2348 ]
2349 );
2350 }
2351
2352 #[test]
2353 fn exact_binding_and_poll_budget_fail_without_accidental_spend() {
2354 let mut authority = authority();
2355 let (mut correlation, _) = register(&mut authority, SECRET);
2356 let correct_assessment = correlation.assessment_id().clone();
2357 let correct_case = correlation.verification_case().clone();
2358 let initial = correlation.remaining_polls();
2359 assert_eq!(
2360 correlation
2361 .begin_poll(
2362 &assessment("assessment:wrong"),
2363 &correct_case,
2364 OastMonotonicTime::from_millis(1_001),
2365 )
2366 .unwrap_err(),
2367 OastError::BindingMismatch
2368 );
2369 let wrong_case = VerificationCase::new(
2370 correct_case.id(),
2371 subject("resource:wrong"),
2372 correct_case.action_id(),
2373 correct_case.hypothesis_id(),
2374 )
2375 .unwrap();
2376 assert_eq!(
2377 correlation
2378 .begin_poll(
2379 &correct_assessment,
2380 &wrong_case,
2381 OastMonotonicTime::from_millis(1_001),
2382 )
2383 .unwrap_err(),
2384 OastError::BindingMismatch
2385 );
2386 assert_eq!(correlation.remaining_polls(), initial);
2387
2388 drop(
2389 correlation
2390 .begin_poll(
2391 &correct_assessment,
2392 &correct_case,
2393 OastMonotonicTime::from_millis(1_001),
2394 )
2395 .unwrap(),
2396 );
2397 assert_eq!(correlation.remaining_polls(), initial - 1);
2398 assert_eq!(correlation.abandoned_polls(), 1);
2399 }
2400
2401 #[test]
2402 fn event_models_are_typed_raw_free_and_protocol_grants_fail_closed() {
2403 let dns_data = OastDnsEvent::new(OastDnsTransport::Tcp, OastDnsRecordType::Txt);
2404 assert_eq!(dns_data.transport(), OastDnsTransport::Tcp);
2405 assert_eq!(dns_data.record_type(), OastDnsRecordType::Txt);
2406 let http_data = OastHttpEvent::new(OastHttpScheme::Https, OastHttpMethod::Post, true);
2407 assert_eq!(http_data.scheme(), OastHttpScheme::Https);
2408 assert_eq!(http_data.method(), OastHttpMethod::Post);
2409 assert!(http_data.body_present());
2410
2411 let mut authority = OastCorrelationAuthority::new(
2412 epoch(EPOCH),
2413 assessment("assessment:1"),
2414 limits(2, 2, 2, 2),
2415 );
2416 let (mut correlation, _) = authority
2417 .register(
2418 verification_case(),
2419 token(SECRET),
2420 protocols(true, false),
2421 OastMonotonicTime::from_millis(1_000),
2422 OastLifetime::from_millis(100).unwrap(),
2423 OastPollBudget::new(2).unwrap(),
2424 )
2425 .unwrap();
2426 assert!(correlation.allowed_protocols().allows_dns());
2427 assert!(!correlation.allowed_protocols().allows_http());
2428 let id = correlation.correlation_id().clone();
2429 let assessment = correlation.assessment_id().clone();
2430 let case = correlation.verification_case().clone();
2431 let mut permit = correlation
2432 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2433 .unwrap();
2434 let error = OastError::ProtocolNotAllowed {
2435 protocol: OastEventProtocol::Http,
2436 };
2437 assert_eq!(
2438 permit
2439 .stage_event(&id, http(1), OastMonotonicTime::from_millis(1_002))
2440 .unwrap_err(),
2441 error
2442 );
2443 assert_eq!(
2444 permit
2445 .finish(OastMonotonicTime::from_millis(1_003))
2446 .unwrap_err(),
2447 error
2448 );
2449 assert_eq!(correlation.unique_events(), 0);
2450 }
2451
2452 #[test]
2453 fn atomic_completion_uses_event_key_and_protocol_family() {
2454 let mut authority = authority();
2455 let (mut correlation, registration) = register(&mut authority, SECRET);
2456 let id = correlation.correlation_id().clone();
2457 let assessment = correlation.assessment_id().clone();
2458 let case = correlation.verification_case().clone();
2459
2460 let mut first = correlation
2461 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2462 .unwrap();
2463 first
2464 .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_002))
2465 .unwrap();
2466 first
2467 .stage_event(&id, dns(2), OastMonotonicTime::from_millis(1_003))
2468 .unwrap();
2469 let first_receipt = first.finish(OastMonotonicTime::from_millis(1_004)).unwrap();
2470 assert_eq!(first_receipt.accepted_events(), 2);
2471 assert_eq!(first_receipt.duplicate_events(), 0);
2472 assert_eq!(first_receipt.event_receipts().len(), 2);
2473 assert_eq!(first_receipt.binding_id(), registration.binding_id());
2474 assert_eq!(
2475 first_receipt.correlation_id(),
2476 registration.correlation_id()
2477 );
2478
2479 let mut second = correlation
2480 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_005))
2481 .unwrap();
2482 second
2483 .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_006))
2484 .unwrap();
2485 second
2486 .stage_event(
2487 &id,
2488 OastEvent::Dns(
2489 event_key(3),
2490 OastDnsEvent::new(OastDnsTransport::Tcp, OastDnsRecordType::Txt),
2491 ),
2492 OastMonotonicTime::from_millis(1_007),
2493 )
2494 .unwrap();
2495 second
2496 .stage_event(
2497 &id,
2498 OastEvent::Dns(
2499 event_key(3),
2500 OastDnsEvent::new(OastDnsTransport::Tcp, OastDnsRecordType::Txt),
2501 ),
2502 OastMonotonicTime::from_millis(1_008),
2503 )
2504 .unwrap();
2505 let second_receipt = second
2506 .finish(OastMonotonicTime::from_millis(1_009))
2507 .unwrap();
2508 assert_eq!(second_receipt.accepted_events(), 1);
2509 assert_eq!(second_receipt.duplicate_events(), 2);
2510 assert_eq!(
2511 second_receipt.event_receipts()[0].disposition(),
2512 OastEventDisposition::DuplicateSuppressed
2513 );
2514 assert_eq!(correlation.unique_events(), 3);
2515 assert_eq!(correlation.accepted_events(), 3);
2516 assert_eq!(correlation.duplicate_events(), 2);
2517 assert_eq!(correlation.abandoned_polls(), 0);
2518 }
2519
2520 #[test]
2521 fn poll_receipts_are_canonical_across_provider_batch_order() {
2522 fn complete(order: [u8; 3]) -> OastPollReceipt {
2523 let mut authority = authority();
2524 let (mut correlation, _) = register(&mut authority, SECRET);
2525 let id = correlation.correlation_id().clone();
2526 let assessment = correlation.assessment_id().clone();
2527 let case = correlation.verification_case().clone();
2528 let mut permit = correlation
2529 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2530 .unwrap();
2531 for marker in order {
2532 permit
2533 .stage_event(
2534 &id,
2535 dns(marker),
2536 OastMonotonicTime::from_millis(1_010 + u64::from(marker)),
2537 )
2538 .unwrap();
2539 }
2540 permit
2541 .finish(OastMonotonicTime::from_millis(1_020))
2542 .unwrap()
2543 }
2544
2545 let ascending = complete([1, 2, 3]);
2546 let reordered = complete([3, 1, 2]);
2547 assert_eq!(ascending, reordered);
2548 assert_eq!(
2549 ascending
2550 .event_receipts()
2551 .iter()
2552 .map(|receipt| receipt.event_key().clone())
2553 .collect::<Vec<_>>(),
2554 vec![event_key(1), event_key(2), event_key(3)]
2555 );
2556 }
2557
2558 #[test]
2559 fn same_key_same_protocol_is_duplicate_despite_metadata_changes() {
2560 let mut authority = authority();
2561 let (mut correlation, _) = register(&mut authority, SECRET);
2562 let id = correlation.correlation_id().clone();
2563 let assessment = correlation.assessment_id().clone();
2564 let case = correlation.verification_case().clone();
2565
2566 let mut permit = correlation
2567 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2568 .unwrap();
2569 permit
2570 .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_002))
2571 .unwrap();
2572 permit
2573 .stage_event(
2574 &id,
2575 OastEvent::Dns(
2576 event_key(1),
2577 OastDnsEvent::new(OastDnsTransport::Tcp, OastDnsRecordType::Txt),
2578 ),
2579 OastMonotonicTime::from_millis(1_003),
2580 )
2581 .unwrap();
2582 let receipt = permit
2583 .finish(OastMonotonicTime::from_millis(1_004))
2584 .unwrap();
2585 assert_eq!(receipt.accepted_events(), 1);
2586 assert_eq!(receipt.duplicate_events(), 1);
2587 assert_eq!(correlation.unique_events(), 1);
2588 assert_eq!(correlation.accepted_events(), 1);
2589 assert_eq!(correlation.duplicate_events(), 1);
2590 assert_eq!(correlation.abandoned_polls(), 0);
2591 }
2592
2593 #[test]
2594 fn protocol_conflict_rejects_the_entire_batch_without_event_mutation() {
2595 let mut authority = authority();
2596 let (mut correlation, _) = register(&mut authority, SECRET);
2597 let id = correlation.correlation_id().clone();
2598 let assessment = correlation.assessment_id().clone();
2599 let case = correlation.verification_case().clone();
2600
2601 let mut accepted = correlation
2602 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2603 .unwrap();
2604 accepted
2605 .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_002))
2606 .unwrap();
2607 accepted
2608 .finish(OastMonotonicTime::from_millis(1_003))
2609 .unwrap();
2610
2611 let mut conflicting = correlation
2612 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_004))
2613 .unwrap();
2614 conflicting
2615 .stage_event(&id, http(1), OastMonotonicTime::from_millis(1_005))
2616 .unwrap();
2617 assert_eq!(
2618 conflicting
2619 .finish(OastMonotonicTime::from_millis(1_006))
2620 .unwrap_err(),
2621 OastError::EventKeyProtocolConflict
2622 );
2623 assert_eq!(correlation.unique_events(), 1);
2624 assert_eq!(correlation.accepted_events(), 1);
2625 assert_eq!(correlation.duplicate_events(), 0);
2626 assert_eq!(correlation.abandoned_polls(), 1);
2627 }
2628
2629 #[test]
2630 fn poll_and_unique_limits_reject_whole_batches() {
2631 let mut authority = OastCorrelationAuthority::new(
2632 epoch(EPOCH),
2633 assessment("assessment:1"),
2634 limits(2, 3, 2, 1),
2635 );
2636 let (mut correlation, _) = register(&mut authority, SECRET);
2637 let id = correlation.correlation_id().clone();
2638 let assessment = correlation.assessment_id().clone();
2639 let case = correlation.verification_case().clone();
2640
2641 let mut too_many_for_poll = correlation
2642 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2643 .unwrap();
2644 too_many_for_poll
2645 .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_002))
2646 .unwrap();
2647 too_many_for_poll
2648 .stage_event(&id, dns(2), OastMonotonicTime::from_millis(1_003))
2649 .unwrap();
2650 let poll_error = OastError::PollEventLimitExceeded { maximum: 2 };
2651 assert_eq!(
2652 too_many_for_poll
2653 .stage_event(&id, dns(3), OastMonotonicTime::from_millis(1_004))
2654 .unwrap_err(),
2655 poll_error
2656 );
2657 assert_eq!(
2658 too_many_for_poll
2659 .finish(OastMonotonicTime::from_millis(1_005))
2660 .unwrap_err(),
2661 poll_error
2662 );
2663 assert_eq!(correlation.unique_events(), 0);
2664 assert_eq!(correlation.abandoned_polls(), 1);
2665
2666 let mut too_many_unique = correlation
2667 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_006))
2668 .unwrap();
2669 too_many_unique
2670 .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_007))
2671 .unwrap();
2672 too_many_unique
2673 .stage_event(&id, dns(2), OastMonotonicTime::from_millis(1_008))
2674 .unwrap();
2675 assert_eq!(
2676 too_many_unique
2677 .finish(OastMonotonicTime::from_millis(1_009))
2678 .unwrap_err(),
2679 OastError::UniqueEventLimitExceeded { maximum: 1 }
2680 );
2681 assert_eq!(correlation.unique_events(), 0);
2682 assert_eq!(correlation.abandoned_polls(), 2);
2683 }
2684
2685 #[test]
2686 fn rejected_staging_poison_is_atomic_and_time_is_monotonic() {
2687 let mut correlation_authority = authority();
2688 let (mut correlation, _) = register(&mut correlation_authority, SECRET);
2689 let id = correlation.correlation_id().clone();
2690 let assessment = correlation.assessment_id().clone();
2691 let case = correlation.verification_case().clone();
2692 let mut wrong_id_bytes = SECRET;
2693 wrong_id_bytes[0] ^= 1;
2694 let mut other_authority = authority();
2695 let (_, other_receipt) = register(&mut other_authority, wrong_id_bytes);
2696
2697 let mut wrong_route = correlation
2698 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_001))
2699 .unwrap();
2700 assert_eq!(
2701 wrong_route
2702 .stage_event(
2703 other_receipt.correlation_id(),
2704 dns(1),
2705 OastMonotonicTime::from_millis(1_002),
2706 )
2707 .unwrap_err(),
2708 OastError::CorrelationMismatch
2709 );
2710 assert_eq!(
2711 wrong_route
2712 .finish(OastMonotonicTime::from_millis(1_003))
2713 .unwrap_err(),
2714 OastError::CorrelationMismatch
2715 );
2716
2717 let mut regressed = correlation
2718 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_004))
2719 .unwrap();
2720 regressed
2721 .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_006))
2722 .unwrap();
2723 let regression = OastError::ClockRegressed {
2724 previous: OastMonotonicTime::from_millis(1_004),
2725 current: OastMonotonicTime::from_millis(1_003),
2726 };
2727 assert_eq!(
2728 regressed
2729 .stage_event(&id, dns(2), OastMonotonicTime::from_millis(1_003))
2730 .unwrap_err(),
2731 regression
2732 );
2733 assert_eq!(
2734 regressed
2735 .finish(OastMonotonicTime::from_millis(1_007))
2736 .unwrap_err(),
2737 regression
2738 );
2739 assert_eq!(correlation.unique_events(), 0);
2740 assert_eq!(correlation.abandoned_polls(), 2);
2741 }
2742
2743 #[test]
2744 fn cancellation_and_expiry_are_sticky_typed_terminal_states() {
2745 let mut authority = authority();
2746 let (mut cancelled, _) = register(&mut authority, SECRET);
2747 let assessment = cancelled.assessment_id().clone();
2748 let case = cancelled.verification_case().clone();
2749 let receipt = cancelled
2750 .cancel(&assessment, &case, OastMonotonicTime::from_millis(1_010))
2751 .unwrap();
2752 assert_eq!(receipt.state(), OastCorrelationState::Cancelled);
2753 assert_eq!(receipt.terminal_at().as_millis(), 1_010);
2754 assert_eq!(cancelled.state(), OastCorrelationState::Cancelled);
2755 assert_eq!(cancelled.terminal_receipt(), Some(receipt));
2756 assert_eq!(
2757 cancelled
2758 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_011),)
2759 .unwrap_err(),
2760 OastError::Cancelled
2761 );
2762
2763 let mut next_secret = SECRET;
2764 next_secret[0] ^= 1;
2765 let (mut expired, _) = register(&mut authority, next_secret);
2766 let assessment = expired.assessment_id().clone();
2767 let case = expired.verification_case().clone();
2768 let remaining = expired.remaining_polls();
2769 assert_eq!(
2770 expired
2771 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_100),)
2772 .unwrap_err(),
2773 OastError::Expired {
2774 expires_at: OastMonotonicTime::from_millis(1_100),
2775 }
2776 );
2777 assert_eq!(expired.state(), OastCorrelationState::Expired);
2778 assert_eq!(expired.remaining_polls(), remaining);
2779 let terminal = expired.terminal_receipt().unwrap();
2780 assert_eq!(terminal.state(), OastCorrelationState::Expired);
2781 assert_eq!(terminal.terminal_at().as_millis(), 1_100);
2782 }
2783
2784 #[test]
2785 fn expiry_during_a_poll_commits_no_staged_events() {
2786 let mut authority = authority();
2787 let (mut correlation, _) = register(&mut authority, SECRET);
2788 let id = correlation.correlation_id().clone();
2789 let assessment = correlation.assessment_id().clone();
2790 let case = correlation.verification_case().clone();
2791 let mut permit = correlation
2792 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_090))
2793 .unwrap();
2794 permit
2795 .stage_event(&id, dns(1), OastMonotonicTime::from_millis(1_099))
2796 .unwrap();
2797 assert_eq!(
2798 permit
2799 .finish(OastMonotonicTime::from_millis(1_100))
2800 .unwrap_err(),
2801 OastError::Expired {
2802 expires_at: OastMonotonicTime::from_millis(1_100),
2803 }
2804 );
2805 assert_eq!(correlation.unique_events(), 0);
2806 assert_eq!(correlation.state(), OastCorrelationState::Expired);
2807 assert_eq!(correlation.abandoned_polls(), 1);
2808 }
2809
2810 #[test]
2811 fn terminal_expiry_outranks_a_poisoned_batch_at_completion() {
2812 let mut correlation_authority = authority();
2813 let (mut correlation, _) = register(&mut correlation_authority, SECRET);
2814 let assessment = correlation.assessment_id().clone();
2815 let case = correlation.verification_case().clone();
2816 let mut wrong_secret = SECRET;
2817 wrong_secret[0] ^= 1;
2818 let mut other_authority = authority();
2819 let (_, other) = register(&mut other_authority, wrong_secret);
2820
2821 let mut permit = correlation
2822 .begin_poll(&assessment, &case, OastMonotonicTime::from_millis(1_090))
2823 .unwrap();
2824 assert_eq!(
2825 permit
2826 .stage_event(
2827 other.correlation_id(),
2828 dns(1),
2829 OastMonotonicTime::from_millis(1_099),
2830 )
2831 .unwrap_err(),
2832 OastError::CorrelationMismatch
2833 );
2834 assert_eq!(
2835 permit
2836 .finish(OastMonotonicTime::from_millis(1_100))
2837 .unwrap_err(),
2838 OastError::Expired {
2839 expires_at: OastMonotonicTime::from_millis(1_100),
2840 }
2841 );
2842 assert_eq!(correlation.state(), OastCorrelationState::Expired);
2843 assert_eq!(correlation.unique_events(), 0);
2844 assert_eq!(correlation.abandoned_polls(), 1);
2845 }
2846}