1use std::{collections::BTreeSet, fmt};
11
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14use url::Url;
15
16pub const JWT_TARGET_ACCEPTANCE_AUDIT_SCHEMA: &str = "security.jwt-target-acceptance-audit/v1";
18pub const JWT_TARGET_ACCEPTANCE_POLICY_SCHEMA: &str = "security.jwt-target-acceptance-policy/v1";
20pub const JWT_TARGET_ACCEPTANCE_POLICY_ID: &str =
22 "termivar.jwt-target-acceptance/exact-origin-json-marker-v1";
23
24pub const JWT_TARGET_ACCEPTANCE_VALID_BASELINE_ACTION_ID: &str =
26 "web.review.jwt-target-acceptance.valid-baseline@1";
27pub const JWT_TARGET_ACCEPTANCE_ANONYMOUS_CONTROL_ACTION_ID: &str =
29 "web.review.jwt-target-acceptance.anonymous-control@1";
30pub const JWT_TARGET_ACCEPTANCE_INVALID_CONTROL_ACTION_ID: &str =
32 "web.review.jwt-target-acceptance.invalid-control@1";
33pub const JWT_TARGET_ACCEPTANCE_VALID_ACTION_ID: &str =
35 JWT_TARGET_ACCEPTANCE_VALID_BASELINE_ACTION_ID;
36pub const JWT_TARGET_ACCEPTANCE_ANONYMOUS_ACTION_ID: &str =
38 JWT_TARGET_ACCEPTANCE_ANONYMOUS_CONTROL_ACTION_ID;
39pub const JWT_TARGET_ACCEPTANCE_INVALID_ACTION_ID: &str =
41 JWT_TARGET_ACCEPTANCE_INVALID_CONTROL_ACTION_ID;
42
43pub const MAX_JWT_TARGET_ACCEPTANCE_REQUESTS: u8 = 6;
45pub const MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS: u8 = 2;
47pub const MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES: u64 = 64 * 1024;
49pub const MAX_JWT_TARGET_ACCEPTANCE_TOTAL_RESPONSE_BYTES: u64 = 256 * 1024;
51pub const MAX_JWT_TARGET_ACCEPTANCE_EVIDENCE_REFERENCE_BYTES: usize = 128;
53
54const MAX_POLICY_REFERENCE_BYTES: usize = 64;
55const MAX_RESOURCE_REFERENCE_BYTES: usize = 64;
56const MAX_JSON_FIELD_BYTES: usize = 128;
57const JWT_TARGET_PREPARATION_BINDING_BYTES: usize = 32;
58const JWT_TARGET_PREPARATION_REFERENCE_PREFIX: &str = "jwt-target-preparation:";
59
60#[derive(Clone, PartialEq, Eq)]
70pub(crate) struct JwtTargetAcceptanceBinding([u8; JWT_TARGET_PREPARATION_BINDING_BYTES]);
71
72impl JwtTargetAcceptanceBinding {
73 pub(crate) const fn from_random_bytes(
74 bytes: [u8; JWT_TARGET_PREPARATION_BINDING_BYTES],
75 ) -> Self {
76 Self(bytes)
77 }
78
79 pub(crate) fn reference(&self) -> String {
80 const HEX: &[u8; 16] = b"0123456789abcdef";
81 let mut reference = String::with_capacity(
82 JWT_TARGET_PREPARATION_REFERENCE_PREFIX.len()
83 + JWT_TARGET_PREPARATION_BINDING_BYTES * 2,
84 );
85 reference.push_str(JWT_TARGET_PREPARATION_REFERENCE_PREFIX);
86 for byte in self.0 {
87 reference.push(char::from(HEX[usize::from(byte >> 4)]));
88 reference.push(char::from(HEX[usize::from(byte & 0x0f)]));
89 }
90 reference
91 }
92
93 #[cfg(test)]
94 pub(crate) const fn synthetic_for_test(tag: u8) -> Self {
95 Self([tag; JWT_TARGET_PREPARATION_BINDING_BYTES])
96 }
97}
98
99impl fmt::Debug for JwtTargetAcceptanceBinding {
100 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101 formatter.write_str("JwtTargetAcceptanceBinding(<opaque-per-preparation>)")
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108#[non_exhaustive]
109pub enum JwtTargetAcceptanceMethod {
110 Get,
112}
113
114pub struct JwtTargetAcceptancePolicy {
120 operator_policy_reference: String,
121 operator_policy_revision: String,
122 application: Url,
123 resource: Url,
124 resource_reference: String,
125 success_json_field: String,
126}
127
128impl JwtTargetAcceptancePolicy {
129 pub fn new(
140 operator_policy_identity: (&str, &str),
141 application: Url,
142 resource: Url,
143 resource_reference: &str,
144 success_json_field: &str,
145 ) -> Result<Self, JwtTargetAcceptancePolicyError> {
146 let (operator_policy_reference, operator_policy_revision) = operator_policy_identity;
147 if !valid_slug(operator_policy_reference, MAX_POLICY_REFERENCE_BYTES) {
148 return Err(JwtTargetAcceptancePolicyError::InvalidOperatorPolicyReference);
149 }
150 if !valid_slug(operator_policy_revision, MAX_POLICY_REFERENCE_BYTES) {
151 return Err(JwtTargetAcceptancePolicyError::InvalidOperatorPolicyRevision);
152 }
153 validate_http_url(&application)
154 .map_err(|_| JwtTargetAcceptancePolicyError::InvalidApplication)?;
155 validate_http_url(&resource)
156 .map_err(|_| JwtTargetAcceptancePolicyError::InvalidResource)?;
157 if !same_exact_origin(&application, &resource) {
158 return Err(JwtTargetAcceptancePolicyError::ForeignResourceOrigin);
159 }
160 if !path_within_application(&application, &resource) {
161 return Err(JwtTargetAcceptancePolicyError::ResourceOutsideApplication);
162 }
163 if !valid_slug(resource_reference, MAX_RESOURCE_REFERENCE_BYTES) {
164 return Err(JwtTargetAcceptancePolicyError::InvalidResourceReference);
165 }
166 if !valid_json_field(success_json_field) {
167 return Err(JwtTargetAcceptancePolicyError::InvalidSuccessJsonField);
168 }
169
170 Ok(Self {
171 operator_policy_reference: operator_policy_reference.to_owned(),
172 operator_policy_revision: operator_policy_revision.to_owned(),
173 application,
174 resource,
175 resource_reference: resource_reference.to_owned(),
176 success_json_field: success_json_field.to_owned(),
177 })
178 }
179
180 pub const fn schema(&self) -> &'static str {
182 JWT_TARGET_ACCEPTANCE_POLICY_SCHEMA
183 }
184
185 pub const fn policy_id(&self) -> &'static str {
187 JWT_TARGET_ACCEPTANCE_POLICY_ID
188 }
189
190 pub const fn method(&self) -> JwtTargetAcceptanceMethod {
192 JwtTargetAcceptanceMethod::Get
193 }
194
195 pub fn operator_policy_reference(&self) -> &str {
197 &self.operator_policy_reference
198 }
199
200 pub fn operator_policy_revision(&self) -> &str {
202 &self.operator_policy_revision
203 }
204
205 pub const fn application(&self) -> &Url {
210 &self.application
211 }
212
213 pub const fn resource(&self) -> &Url {
218 &self.resource
219 }
220
221 pub fn resource_reference(&self) -> &str {
223 &self.resource_reference
224 }
225
226 pub fn success_json_field(&self) -> &str {
231 &self.success_json_field
232 }
233
234 pub const fn max_requests(&self) -> u8 {
236 MAX_JWT_TARGET_ACCEPTANCE_REQUESTS
237 }
238
239 pub const fn max_active_requests(&self) -> u8 {
241 MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS
242 }
243
244 pub const fn max_response_bytes(&self) -> u64 {
246 MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES
247 }
248
249 pub const fn max_total_response_bytes(&self) -> u64 {
251 MAX_JWT_TARGET_ACCEPTANCE_TOTAL_RESPONSE_BYTES
252 }
253}
254
255impl fmt::Debug for JwtTargetAcceptancePolicy {
256 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
257 formatter
258 .debug_struct("JwtTargetAcceptancePolicy")
259 .field("operator_policy_reference", &self.operator_policy_reference)
260 .field("operator_policy_revision", &self.operator_policy_revision)
261 .field("application", &"<redacted>")
262 .field("resource", &"<redacted>")
263 .field("resource_reference", &self.resource_reference)
264 .field("success_json_field", &"<redacted>")
265 .field("method", &JwtTargetAcceptanceMethod::Get)
266 .finish()
267 }
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
272#[non_exhaustive]
273pub enum JwtTargetAcceptancePolicyError {
274 #[error("JWT target-acceptance operator policy reference is invalid")]
276 InvalidOperatorPolicyReference,
277 #[error("JWT target-acceptance operator policy revision is invalid")]
279 InvalidOperatorPolicyRevision,
280 #[error("JWT target-acceptance application URL is invalid")]
282 InvalidApplication,
283 #[error("JWT target-acceptance resource URL is invalid")]
285 InvalidResource,
286 #[error("JWT target-acceptance resource has a foreign origin")]
288 ForeignResourceOrigin,
289 #[error("JWT target-acceptance resource is outside the application path")]
291 ResourceOutsideApplication,
292 #[error("JWT target-acceptance resource reference is invalid")]
294 InvalidResourceReference,
295 #[error("JWT target-acceptance JSON marker field is invalid")]
297 InvalidSuccessJsonField,
298}
299
300fn valid_slug(value: &str, maximum: usize) -> bool {
301 !value.is_empty()
302 && value.len() <= maximum
303 && value.bytes().enumerate().all(|(index, byte)| {
304 byte.is_ascii_lowercase()
305 || byte.is_ascii_digit()
306 || (index > 0 && matches!(byte, b'-' | b'_' | b'.'))
307 })
308}
309
310fn valid_json_field(value: &str) -> bool {
311 !value.is_empty()
312 && value.len() <= MAX_JSON_FIELD_BYTES
313 && value.bytes().enumerate().all(|(index, byte)| {
314 byte.is_ascii_alphanumeric()
315 || matches!(byte, b'_' | b'-')
316 || (index > 0 && matches!(byte, b'.' | b':'))
317 })
318}
319
320fn validate_http_url(url: &Url) -> Result<(), ()> {
321 if !matches!(url.scheme(), "http" | "https")
322 || url.host().is_none()
323 || url.port_or_known_default().is_none()
324 || !url.username().is_empty()
325 || url.password().is_some()
326 || url.query().is_some()
327 || url.fragment().is_some()
328 || !url.path().starts_with('/')
329 || url.path().contains('%')
330 || url.path().contains('\\')
331 || url.path().contains("//")
332 {
333 return Err(());
334 }
335 Ok(())
336}
337
338fn same_exact_origin(application: &Url, resource: &Url) -> bool {
339 application.scheme() == resource.scheme()
340 && application.host() == resource.host()
341 && application.port_or_known_default() == resource.port_or_known_default()
342}
343
344fn path_within_application(application: &Url, resource: &Url) -> bool {
345 let application_path = application.path();
346 let resource_path = resource.path();
347 if application_path == "/" {
348 return true;
349 }
350 let boundary = application_path.trim_end_matches('/');
351 resource_path == boundary
352 || resource_path
353 .strip_prefix(boundary)
354 .is_some_and(|suffix| suffix.starts_with('/'))
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "snake_case")]
360#[non_exhaustive]
361pub enum JwtTargetAcceptanceLegStage {
362 Candidate,
364 Replay,
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(rename_all = "snake_case")]
371#[non_exhaustive]
372pub enum JwtTargetAcceptanceActivity {
373 Passive,
375 Active,
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
381#[serde(rename_all = "snake_case")]
382#[non_exhaustive]
383pub enum JwtTargetAcceptanceLegRole {
384 ValidCandidate,
386 AnonymousCandidate,
388 InvalidCandidate,
390 ValidReplay,
392 AnonymousReplay,
394 InvalidReplay,
396}
397
398impl JwtTargetAcceptanceLegRole {
399 pub const ORDERED: [Self; MAX_JWT_TARGET_ACCEPTANCE_REQUESTS as usize] = [
401 Self::ValidCandidate,
402 Self::AnonymousCandidate,
403 Self::InvalidCandidate,
404 Self::ValidReplay,
405 Self::AnonymousReplay,
406 Self::InvalidReplay,
407 ];
408
409 pub const fn action_id(self) -> &'static str {
411 match self {
412 Self::ValidCandidate | Self::ValidReplay => {
413 JWT_TARGET_ACCEPTANCE_VALID_BASELINE_ACTION_ID
414 },
415 Self::AnonymousCandidate | Self::AnonymousReplay => {
416 JWT_TARGET_ACCEPTANCE_ANONYMOUS_CONTROL_ACTION_ID
417 },
418 Self::InvalidCandidate | Self::InvalidReplay => {
419 JWT_TARGET_ACCEPTANCE_INVALID_CONTROL_ACTION_ID
420 },
421 }
422 }
423
424 pub const fn stage(self) -> JwtTargetAcceptanceLegStage {
426 match self {
427 Self::ValidCandidate | Self::AnonymousCandidate | Self::InvalidCandidate => {
428 JwtTargetAcceptanceLegStage::Candidate
429 },
430 Self::ValidReplay | Self::AnonymousReplay | Self::InvalidReplay => {
431 JwtTargetAcceptanceLegStage::Replay
432 },
433 }
434 }
435
436 pub const fn activity(self) -> JwtTargetAcceptanceActivity {
438 if self.is_active() {
439 JwtTargetAcceptanceActivity::Active
440 } else {
441 JwtTargetAcceptanceActivity::Passive
442 }
443 }
444
445 pub const fn is_active(self) -> bool {
447 matches!(self, Self::InvalidCandidate | Self::InvalidReplay)
448 }
449
450 pub const fn requires_authorization(self) -> bool {
452 !self.is_anonymous()
453 }
454
455 pub const fn uses_valid_token(self) -> bool {
457 matches!(self, Self::ValidCandidate | Self::ValidReplay)
458 }
459
460 pub const fn uses_invalid_token(self) -> bool {
462 self.is_active()
463 }
464
465 pub const fn is_anonymous(self) -> bool {
467 matches!(self, Self::AnonymousCandidate | Self::AnonymousReplay)
468 }
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
473#[serde(rename_all = "snake_case")]
474#[non_exhaustive]
475pub enum JwtTargetAcceptanceDispatchStatus {
476 NotDispatched,
478 Dispatched,
480}
481
482#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
484#[serde(rename_all = "snake_case")]
485#[non_exhaustive]
486pub enum JwtTargetAcceptanceCommitStatus {
487 NotCommitted,
489 Committed,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(rename_all = "snake_case")]
496#[non_exhaustive]
497pub enum JwtTargetAcceptanceResponseStatus {
498 NotClassified,
500 CompleteAndClassified,
502 Incomplete,
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
508#[serde(rename_all = "snake_case")]
509#[non_exhaustive]
510pub enum JwtTargetAcceptanceMarkerStatus {
511 Observed,
513 NotObserved,
515 NotEvaluated,
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521#[serde(deny_unknown_fields)]
522pub struct JwtTargetAcceptanceLegFact {
523 role: JwtTargetAcceptanceLegRole,
524 dispatched: JwtTargetAcceptanceDispatchStatus,
525 committed: JwtTargetAcceptanceCommitStatus,
526 response_status: JwtTargetAcceptanceResponseStatus,
527 marker_status: JwtTargetAcceptanceMarkerStatus,
528 retained_response_bytes: u64,
529 accounted_response_bytes: u64,
530 evidence_reference: Option<String>,
531}
532
533impl JwtTargetAcceptanceLegFact {
534 pub fn new(
536 role: JwtTargetAcceptanceLegRole,
537 dispatched: JwtTargetAcceptanceDispatchStatus,
538 committed: JwtTargetAcceptanceCommitStatus,
539 response_status: JwtTargetAcceptanceResponseStatus,
540 marker_status: JwtTargetAcceptanceMarkerStatus,
541 accounted_response_bytes: u64,
542 evidence_reference: Option<String>,
543 ) -> Result<Self, JwtTargetAcceptanceLegFactError> {
544 Self::new_with_transport_accounting(
545 role,
546 dispatched,
547 committed,
548 response_status,
549 marker_status,
550 accounted_response_bytes,
551 accounted_response_bytes,
552 evidence_reference,
553 )
554 }
555
556 #[allow(clippy::too_many_arguments)]
561 pub fn new_with_transport_accounting(
562 role: JwtTargetAcceptanceLegRole,
563 dispatched: JwtTargetAcceptanceDispatchStatus,
564 committed: JwtTargetAcceptanceCommitStatus,
565 response_status: JwtTargetAcceptanceResponseStatus,
566 marker_status: JwtTargetAcceptanceMarkerStatus,
567 retained_response_bytes: u64,
568 accounted_response_bytes: u64,
569 evidence_reference: Option<String>,
570 ) -> Result<Self, JwtTargetAcceptanceLegFactError> {
571 let fact = Self {
572 role,
573 dispatched,
574 committed,
575 response_status,
576 marker_status,
577 retained_response_bytes,
578 accounted_response_bytes,
579 evidence_reference,
580 };
581 fact.validate()?;
582 Ok(fact)
583 }
584
585 pub fn not_dispatched(role: JwtTargetAcceptanceLegRole) -> Self {
587 Self {
588 role,
589 dispatched: JwtTargetAcceptanceDispatchStatus::NotDispatched,
590 committed: JwtTargetAcceptanceCommitStatus::NotCommitted,
591 response_status: JwtTargetAcceptanceResponseStatus::NotClassified,
592 marker_status: JwtTargetAcceptanceMarkerStatus::NotEvaluated,
593 retained_response_bytes: 0,
594 accounted_response_bytes: 0,
595 evidence_reference: None,
596 }
597 }
598
599 pub const fn role(&self) -> JwtTargetAcceptanceLegRole {
601 self.role
602 }
603
604 pub const fn dispatch_status(&self) -> JwtTargetAcceptanceDispatchStatus {
606 self.dispatched
607 }
608
609 pub const fn was_dispatched(&self) -> bool {
611 matches!(
612 self.dispatched,
613 JwtTargetAcceptanceDispatchStatus::Dispatched
614 )
615 }
616
617 pub const fn commit_status(&self) -> JwtTargetAcceptanceCommitStatus {
619 self.committed
620 }
621
622 pub const fn was_committed(&self) -> bool {
624 matches!(self.committed, JwtTargetAcceptanceCommitStatus::Committed)
625 }
626
627 pub const fn response_status(&self) -> JwtTargetAcceptanceResponseStatus {
629 self.response_status
630 }
631
632 pub const fn marker_status(&self) -> JwtTargetAcceptanceMarkerStatus {
634 self.marker_status
635 }
636
637 pub const fn retained_response_bytes(&self) -> u64 {
639 self.retained_response_bytes
640 }
641
642 pub const fn accounted_response_bytes(&self) -> u64 {
644 self.accounted_response_bytes
645 }
646
647 pub fn evidence_reference(&self) -> Option<&str> {
649 self.evidence_reference.as_deref()
650 }
651
652 fn is_complete_classification(&self) -> bool {
653 self.was_dispatched()
654 && self.was_committed()
655 && self.response_status == JwtTargetAcceptanceResponseStatus::CompleteAndClassified
656 && matches!(
657 self.marker_status,
658 JwtTargetAcceptanceMarkerStatus::Observed
659 | JwtTargetAcceptanceMarkerStatus::NotObserved
660 )
661 }
662
663 fn validate(&self) -> Result<(), JwtTargetAcceptanceLegFactError> {
664 if self.retained_response_bytes > MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES {
665 return Err(JwtTargetAcceptanceLegFactError::ResponseBytesExceeded);
666 }
667 if self.accounted_response_bytes < self.retained_response_bytes {
668 return Err(JwtTargetAcceptanceLegFactError::TransportBytesBelowRetained);
669 }
670 if let Some(reference) = self.evidence_reference.as_deref() {
671 if !valid_evidence_reference(reference) {
672 return Err(JwtTargetAcceptanceLegFactError::InvalidEvidenceReference);
673 }
674 }
675
676 let valid = match (self.dispatched, self.committed) {
677 (
678 JwtTargetAcceptanceDispatchStatus::NotDispatched,
679 JwtTargetAcceptanceCommitStatus::NotCommitted,
680 ) => {
681 self.response_status == JwtTargetAcceptanceResponseStatus::NotClassified
682 && self.marker_status == JwtTargetAcceptanceMarkerStatus::NotEvaluated
683 && self.retained_response_bytes == 0
684 && self.accounted_response_bytes == 0
685 && self.evidence_reference.is_none()
686 },
687 (
688 JwtTargetAcceptanceDispatchStatus::Dispatched,
689 JwtTargetAcceptanceCommitStatus::NotCommitted,
690 ) => {
691 self.response_status != JwtTargetAcceptanceResponseStatus::CompleteAndClassified
692 && self.marker_status == JwtTargetAcceptanceMarkerStatus::NotEvaluated
693 && self.evidence_reference.is_none()
694 },
695 (
696 JwtTargetAcceptanceDispatchStatus::Dispatched,
697 JwtTargetAcceptanceCommitStatus::Committed,
698 ) => {
699 self.evidence_reference.is_some()
700 && match self.response_status {
701 JwtTargetAcceptanceResponseStatus::CompleteAndClassified => matches!(
702 self.marker_status,
703 JwtTargetAcceptanceMarkerStatus::Observed
704 | JwtTargetAcceptanceMarkerStatus::NotObserved
705 ),
706 JwtTargetAcceptanceResponseStatus::NotClassified
707 | JwtTargetAcceptanceResponseStatus::Incomplete => {
708 self.marker_status == JwtTargetAcceptanceMarkerStatus::NotEvaluated
709 },
710 }
711 },
712 (
713 JwtTargetAcceptanceDispatchStatus::NotDispatched,
714 JwtTargetAcceptanceCommitStatus::Committed,
715 ) => false,
716 };
717
718 if valid {
719 Ok(())
720 } else {
721 Err(JwtTargetAcceptanceLegFactError::InconsistentState)
722 }
723 }
724}
725
726fn valid_evidence_reference(value: &str) -> bool {
727 !value.is_empty()
728 && value.len() <= MAX_JWT_TARGET_ACCEPTANCE_EVIDENCE_REFERENCE_BYTES
729 && value.bytes().all(|byte| byte.is_ascii_graphic())
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, Deserialize)]
734#[serde(rename_all = "snake_case")]
735#[non_exhaustive]
736pub enum JwtTargetAcceptanceLegFactError {
737 #[error("JWT target-acceptance leg state is inconsistent")]
739 InconsistentState,
740 #[error("JWT target-acceptance leg exceeds its response-byte limit")]
742 ResponseBytesExceeded,
743 #[error("JWT target-acceptance transport bytes cannot be below retained bytes")]
745 TransportBytesBelowRetained,
746 #[error("JWT target-acceptance evidence reference is invalid")]
748 InvalidEvidenceReference,
749}
750
751#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
753#[serde(rename_all = "snake_case")]
754#[non_exhaustive]
755pub enum JwtTargetAcceptanceDimension {
756 ObservedStable,
758 NotObservedStable,
760 Unstable,
762 NotEvaluated,
764 Incomplete,
766}
767
768#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
770#[serde(rename_all = "snake_case")]
771#[non_exhaustive]
772pub enum JwtTargetAcceptanceNotEligibleReason {
773 LocalParsingNotEstablished,
775 LocalPolicyNotEstablished,
777 LocalSignatureNotEstablished,
779 RuntimeAuthorityUnavailable,
781 RequestBudgetUnavailable,
783}
784
785#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
787#[serde(rename_all = "snake_case")]
788#[non_exhaustive]
789pub enum JwtTargetAcceptanceIncompleteReason {
790 NotEligible(JwtTargetAcceptanceNotEligibleReason),
792 LegNotDispatched { role: JwtTargetAcceptanceLegRole },
794 LegNotCommitted { role: JwtTargetAcceptanceLegRole },
796 ResponseIncomplete { role: JwtTargetAcceptanceLegRole },
798 ResponseNotClassified { role: JwtTargetAcceptanceLegRole },
800}
801
802#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
804#[serde(rename_all = "snake_case")]
805#[non_exhaustive]
806pub enum JwtTargetAcceptanceConclusion {
807 InvalidSignatureControlMarkerObservedWithAnonymousControl,
810 PublicOrTokenAgnosticMarkerObserved,
813 InvalidControlMarkerNotObserved,
816 UnstableInconclusive,
818 Inconclusive,
821 Incomplete(JwtTargetAcceptanceIncompleteReason),
823}
824
825#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
827pub struct JwtTargetAcceptanceAccounting {
828 dispatched_request_count: u8,
829 dispatched_passive_request_count: u8,
830 dispatched_active_request_count: u8,
831 committed_response_count: u8,
832 retained_response_bytes: u64,
833 accounted_response_bytes: u64,
834}
835
836impl JwtTargetAcceptanceAccounting {
837 pub const fn dispatched_request_count(self) -> u8 {
839 self.dispatched_request_count
840 }
841
842 pub const fn dispatched_passive_request_count(self) -> u8 {
844 self.dispatched_passive_request_count
845 }
846
847 pub const fn dispatched_active_request_count(self) -> u8 {
849 self.dispatched_active_request_count
850 }
851
852 pub const fn committed_response_count(self) -> u8 {
854 self.committed_response_count
855 }
856
857 pub const fn retained_response_bytes(self) -> u64 {
859 self.retained_response_bytes
860 }
861
862 pub const fn accounted_response_bytes(self) -> u64 {
864 self.accounted_response_bytes
865 }
866}
867
868#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
870pub struct JwtTargetAcceptanceAudit {
871 schema: &'static str,
872 policy: &'static str,
873 operator_policy_reference: String,
874 operator_policy_revision: String,
875 resource_reference: String,
876 method: JwtTargetAcceptanceMethod,
877 legs: Vec<JwtTargetAcceptanceLegFact>,
878 accounting: JwtTargetAcceptanceAccounting,
879 valid_marker: JwtTargetAcceptanceDimension,
880 anonymous_marker: JwtTargetAcceptanceDimension,
881 invalid_marker: JwtTargetAcceptanceDimension,
882 conclusion: JwtTargetAcceptanceConclusion,
883 #[serde(skip)]
884 preparation_binding: Option<JwtTargetAcceptanceBinding>,
885}
886
887impl JwtTargetAcceptanceAudit {
888 pub fn from_legs(
890 policy: &JwtTargetAcceptancePolicy,
891 legs: Vec<JwtTargetAcceptanceLegFact>,
892 ) -> Result<Self, JwtTargetAcceptanceAuditError> {
893 if legs.len() != usize::from(MAX_JWT_TARGET_ACCEPTANCE_REQUESTS) {
894 return Err(JwtTargetAcceptanceAuditError::UnexpectedLegCount);
895 }
896 for (index, (fact, expected_role)) in legs
897 .iter()
898 .zip(JwtTargetAcceptanceLegRole::ORDERED)
899 .enumerate()
900 {
901 if fact.role != expected_role {
902 return Err(JwtTargetAcceptanceAuditError::UnexpectedRoleOrder {
903 ordinal: u8::try_from(index).expect("six roles fit u8"),
904 });
905 }
906 fact.validate()
907 .map_err(|reason| JwtTargetAcceptanceAuditError::InvalidLeg {
908 role: fact.role,
909 reason,
910 })?;
911 }
912
913 validate_active_prerequisite(&legs, 0, 1, 2)?;
914 validate_active_prerequisite(&legs, 3, 4, 5)?;
915 validate_sequential_lifecycle(&legs)?;
916
917 let mut evidence_references = BTreeSet::new();
918 let mut dispatched_request_count = 0_u8;
919 let mut dispatched_passive_request_count = 0_u8;
920 let mut dispatched_active_request_count = 0_u8;
921 let mut committed_response_count = 0_u8;
922 let mut retained_response_bytes = 0_u64;
923 let mut accounted_response_bytes = 0_u64;
924 for fact in &legs {
925 if fact.was_dispatched() {
926 dispatched_request_count = dispatched_request_count
927 .checked_add(1)
928 .ok_or(JwtTargetAcceptanceAuditError::RequestCountExceeded)?;
929 if fact.role.is_active() {
930 dispatched_active_request_count = dispatched_active_request_count
931 .checked_add(1)
932 .ok_or(JwtTargetAcceptanceAuditError::ActiveRequestCountExceeded)?;
933 } else {
934 dispatched_passive_request_count = dispatched_passive_request_count
935 .checked_add(1)
936 .ok_or(JwtTargetAcceptanceAuditError::RequestCountExceeded)?;
937 }
938 }
939 if fact.was_committed() {
940 committed_response_count = committed_response_count
941 .checked_add(1)
942 .ok_or(JwtTargetAcceptanceAuditError::RequestCountExceeded)?;
943 }
944 retained_response_bytes = retained_response_bytes
945 .checked_add(fact.retained_response_bytes)
946 .ok_or(JwtTargetAcceptanceAuditError::TotalResponseBytesExceeded)?;
947 accounted_response_bytes = accounted_response_bytes
948 .checked_add(fact.accounted_response_bytes)
949 .ok_or(JwtTargetAcceptanceAuditError::TransportResponseBytesOverflow)?;
950 if let Some(reference) = fact.evidence_reference.as_deref() {
951 if !evidence_references.insert(reference) {
952 return Err(JwtTargetAcceptanceAuditError::DuplicateEvidenceReference);
953 }
954 }
955 }
956 if dispatched_request_count > MAX_JWT_TARGET_ACCEPTANCE_REQUESTS {
957 return Err(JwtTargetAcceptanceAuditError::RequestCountExceeded);
958 }
959 if dispatched_active_request_count > MAX_JWT_TARGET_ACCEPTANCE_ACTIVE_REQUESTS {
960 return Err(JwtTargetAcceptanceAuditError::ActiveRequestCountExceeded);
961 }
962 if retained_response_bytes > MAX_JWT_TARGET_ACCEPTANCE_TOTAL_RESPONSE_BYTES {
963 return Err(JwtTargetAcceptanceAuditError::TotalResponseBytesExceeded);
964 }
965
966 let accounting = JwtTargetAcceptanceAccounting {
967 dispatched_request_count,
968 dispatched_passive_request_count,
969 dispatched_active_request_count,
970 committed_response_count,
971 retained_response_bytes,
972 accounted_response_bytes,
973 };
974 let valid_marker = pair_dimension(&legs[0], &legs[3]);
975 let anonymous_marker = pair_dimension(&legs[1], &legs[4]);
976 let invalid_marker = pair_dimension(&legs[2], &legs[5]);
977 let conclusion = derive_conclusion(&legs, valid_marker, anonymous_marker, invalid_marker);
978
979 Ok(Self::new_value_free(
980 policy,
981 legs,
982 accounting,
983 valid_marker,
984 anonymous_marker,
985 invalid_marker,
986 conclusion,
987 ))
988 }
989
990 pub fn not_eligible(
992 policy: &JwtTargetAcceptancePolicy,
993 reason: JwtTargetAcceptanceNotEligibleReason,
994 ) -> Self {
995 Self::new_value_free(
996 policy,
997 Vec::new(),
998 JwtTargetAcceptanceAccounting {
999 dispatched_request_count: 0,
1000 dispatched_passive_request_count: 0,
1001 dispatched_active_request_count: 0,
1002 committed_response_count: 0,
1003 retained_response_bytes: 0,
1004 accounted_response_bytes: 0,
1005 },
1006 JwtTargetAcceptanceDimension::NotEvaluated,
1007 JwtTargetAcceptanceDimension::NotEvaluated,
1008 JwtTargetAcceptanceDimension::NotEvaluated,
1009 JwtTargetAcceptanceConclusion::Incomplete(
1010 JwtTargetAcceptanceIncompleteReason::NotEligible(reason),
1011 ),
1012 )
1013 }
1014
1015 fn new_value_free(
1016 policy: &JwtTargetAcceptancePolicy,
1017 legs: Vec<JwtTargetAcceptanceLegFact>,
1018 accounting: JwtTargetAcceptanceAccounting,
1019 valid_marker: JwtTargetAcceptanceDimension,
1020 anonymous_marker: JwtTargetAcceptanceDimension,
1021 invalid_marker: JwtTargetAcceptanceDimension,
1022 conclusion: JwtTargetAcceptanceConclusion,
1023 ) -> Self {
1024 Self {
1025 schema: JWT_TARGET_ACCEPTANCE_AUDIT_SCHEMA,
1026 policy: JWT_TARGET_ACCEPTANCE_POLICY_ID,
1027 operator_policy_reference: policy.operator_policy_reference.clone(),
1028 operator_policy_revision: policy.operator_policy_revision.clone(),
1029 resource_reference: policy.resource_reference.clone(),
1030 method: JwtTargetAcceptanceMethod::Get,
1031 legs,
1032 accounting,
1033 valid_marker,
1034 anonymous_marker,
1035 invalid_marker,
1036 conclusion,
1037 preparation_binding: None,
1038 }
1039 }
1040
1041 pub(crate) fn bind_to_preparation(mut self, binding: JwtTargetAcceptanceBinding) -> Self {
1042 self.preparation_binding = Some(binding);
1043 self
1044 }
1045
1046 pub(crate) const fn preparation_binding(&self) -> Option<&JwtTargetAcceptanceBinding> {
1047 self.preparation_binding.as_ref()
1048 }
1049
1050 pub const fn schema(&self) -> &'static str {
1052 JWT_TARGET_ACCEPTANCE_AUDIT_SCHEMA
1053 }
1054
1055 pub const fn policy(&self) -> &'static str {
1057 JWT_TARGET_ACCEPTANCE_POLICY_ID
1058 }
1059
1060 pub fn operator_policy_reference(&self) -> &str {
1062 &self.operator_policy_reference
1063 }
1064
1065 pub fn operator_policy_revision(&self) -> &str {
1067 &self.operator_policy_revision
1068 }
1069
1070 pub fn resource_reference(&self) -> &str {
1072 &self.resource_reference
1073 }
1074
1075 pub const fn method(&self) -> JwtTargetAcceptanceMethod {
1077 self.method
1078 }
1079
1080 pub fn legs(&self) -> &[JwtTargetAcceptanceLegFact] {
1082 &self.legs
1083 }
1084
1085 pub const fn accounting(&self) -> JwtTargetAcceptanceAccounting {
1087 self.accounting
1088 }
1089
1090 pub const fn valid_marker(&self) -> JwtTargetAcceptanceDimension {
1092 self.valid_marker
1093 }
1094
1095 pub const fn anonymous_marker(&self) -> JwtTargetAcceptanceDimension {
1097 self.anonymous_marker
1098 }
1099
1100 pub const fn invalid_marker(&self) -> JwtTargetAcceptanceDimension {
1102 self.invalid_marker
1103 }
1104
1105 pub const fn conclusion(&self) -> JwtTargetAcceptanceConclusion {
1107 self.conclusion
1108 }
1109}
1110
1111fn validate_active_prerequisite(
1112 legs: &[JwtTargetAcceptanceLegFact],
1113 valid_index: usize,
1114 anonymous_index: usize,
1115 invalid_index: usize,
1116) -> Result<(), JwtTargetAcceptanceAuditError> {
1117 let invalid = &legs[invalid_index];
1118 if invalid.was_dispatched()
1119 && !active_prerequisite_is_established(legs, valid_index, anonymous_index)
1120 {
1121 return Err(
1122 JwtTargetAcceptanceAuditError::ActiveLegPrerequisiteNotEstablished {
1123 role: invalid.role,
1124 },
1125 );
1126 }
1127 Ok(())
1128}
1129
1130fn active_prerequisite_is_established(
1131 legs: &[JwtTargetAcceptanceLegFact],
1132 valid_index: usize,
1133 anonymous_index: usize,
1134) -> bool {
1135 legs[valid_index].is_complete_classification()
1136 && legs[valid_index].marker_status == JwtTargetAcceptanceMarkerStatus::Observed
1137 && legs[anonymous_index].is_complete_classification()
1138 && legs[anonymous_index].marker_status == JwtTargetAcceptanceMarkerStatus::NotObserved
1139}
1140
1141fn validate_sequential_lifecycle(
1142 legs: &[JwtTargetAcceptanceLegFact],
1143) -> Result<(), JwtTargetAcceptanceAuditError> {
1144 let mut stopped = false;
1145 let mut retained_response_bytes = 0_u64;
1146 for (index, leg) in legs.iter().enumerate() {
1147 if stopped {
1148 if leg.was_dispatched() {
1149 return Err(JwtTargetAcceptanceAuditError::DispatchAfterStop { role: leg.role });
1150 }
1151 continue;
1152 }
1153
1154 if !leg.was_dispatched() {
1155 let prerequisite_skip = match index {
1156 2 => !active_prerequisite_is_established(legs, 0, 1),
1157 5 => !active_prerequisite_is_established(legs, 3, 4),
1158 _ => false,
1159 };
1160 if !prerequisite_skip {
1161 stopped = true;
1162 }
1163 continue;
1164 }
1165
1166 retained_response_bytes = retained_response_bytes
1167 .checked_add(leg.retained_response_bytes)
1168 .ok_or(JwtTargetAcceptanceAuditError::TotalResponseBytesExceeded)?;
1169 if !leg.was_committed()
1170 || retained_response_bytes >= MAX_JWT_TARGET_ACCEPTANCE_TOTAL_RESPONSE_BYTES
1171 {
1172 stopped = true;
1173 }
1174 }
1175 Ok(())
1176}
1177
1178fn pair_dimension(
1179 candidate: &JwtTargetAcceptanceLegFact,
1180 replay: &JwtTargetAcceptanceLegFact,
1181) -> JwtTargetAcceptanceDimension {
1182 if candidate.dispatch_status() == JwtTargetAcceptanceDispatchStatus::NotDispatched
1183 && replay.dispatch_status() == JwtTargetAcceptanceDispatchStatus::NotDispatched
1184 {
1185 return JwtTargetAcceptanceDimension::NotEvaluated;
1186 }
1187 if !candidate.is_complete_classification() || !replay.is_complete_classification() {
1188 return JwtTargetAcceptanceDimension::Incomplete;
1189 }
1190 match (candidate.marker_status, replay.marker_status) {
1191 (JwtTargetAcceptanceMarkerStatus::Observed, JwtTargetAcceptanceMarkerStatus::Observed) => {
1192 JwtTargetAcceptanceDimension::ObservedStable
1193 },
1194 (
1195 JwtTargetAcceptanceMarkerStatus::NotObserved,
1196 JwtTargetAcceptanceMarkerStatus::NotObserved,
1197 ) => JwtTargetAcceptanceDimension::NotObservedStable,
1198 _ => JwtTargetAcceptanceDimension::Unstable,
1199 }
1200}
1201
1202fn derive_conclusion(
1203 legs: &[JwtTargetAcceptanceLegFact],
1204 valid: JwtTargetAcceptanceDimension,
1205 anonymous: JwtTargetAcceptanceDimension,
1206 invalid: JwtTargetAcceptanceDimension,
1207) -> JwtTargetAcceptanceConclusion {
1208 if [valid, anonymous, invalid].contains(&JwtTargetAcceptanceDimension::Unstable) {
1209 return JwtTargetAcceptanceConclusion::UnstableInconclusive;
1210 }
1211 if anonymous == JwtTargetAcceptanceDimension::ObservedStable {
1212 return JwtTargetAcceptanceConclusion::PublicOrTokenAgnosticMarkerObserved;
1213 }
1214 if valid == JwtTargetAcceptanceDimension::ObservedStable
1215 && anonymous == JwtTargetAcceptanceDimension::NotObservedStable
1216 && invalid == JwtTargetAcceptanceDimension::ObservedStable
1217 {
1218 return JwtTargetAcceptanceConclusion::InvalidSignatureControlMarkerObservedWithAnonymousControl;
1219 }
1220 if valid == JwtTargetAcceptanceDimension::ObservedStable
1221 && anonymous == JwtTargetAcceptanceDimension::NotObservedStable
1222 && invalid == JwtTargetAcceptanceDimension::NotObservedStable
1223 {
1224 return JwtTargetAcceptanceConclusion::InvalidControlMarkerNotObserved;
1225 }
1226 if [valid, anonymous, invalid].iter().any(|dimension| {
1227 matches!(
1228 dimension,
1229 JwtTargetAcceptanceDimension::Incomplete | JwtTargetAcceptanceDimension::NotEvaluated
1230 )
1231 }) {
1232 return JwtTargetAcceptanceConclusion::Incomplete(first_incomplete_reason(legs));
1233 }
1234 JwtTargetAcceptanceConclusion::Inconclusive
1235}
1236
1237fn first_incomplete_reason(
1238 legs: &[JwtTargetAcceptanceLegFact],
1239) -> JwtTargetAcceptanceIncompleteReason {
1240 for fact in legs {
1241 if !fact.was_dispatched() {
1242 return JwtTargetAcceptanceIncompleteReason::LegNotDispatched { role: fact.role };
1243 }
1244 if !fact.was_committed() {
1245 return JwtTargetAcceptanceIncompleteReason::LegNotCommitted { role: fact.role };
1246 }
1247 match fact.response_status {
1248 JwtTargetAcceptanceResponseStatus::Incomplete => {
1249 return JwtTargetAcceptanceIncompleteReason::ResponseIncomplete { role: fact.role };
1250 },
1251 JwtTargetAcceptanceResponseStatus::NotClassified => {
1252 return JwtTargetAcceptanceIncompleteReason::ResponseNotClassified {
1253 role: fact.role,
1254 };
1255 },
1256 JwtTargetAcceptanceResponseStatus::CompleteAndClassified => {},
1257 }
1258 }
1259 JwtTargetAcceptanceIncompleteReason::LegNotDispatched {
1263 role: JwtTargetAcceptanceLegRole::ValidCandidate,
1264 }
1265}
1266
1267#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
1269#[non_exhaustive]
1270pub enum JwtTargetAcceptanceAuditError {
1271 #[error("JWT target-acceptance audit requires exactly six ordered legs")]
1273 UnexpectedLegCount,
1274 #[error("JWT target-acceptance leg role order is invalid")]
1276 UnexpectedRoleOrder { ordinal: u8 },
1277 #[error("JWT target-acceptance aggregate contains an invalid leg")]
1279 InvalidLeg {
1280 role: JwtTargetAcceptanceLegRole,
1282 reason: JwtTargetAcceptanceLegFactError,
1284 },
1285 #[error("JWT target-acceptance active leg prerequisite is not established")]
1287 ActiveLegPrerequisiteNotEstablished {
1288 role: JwtTargetAcceptanceLegRole,
1290 },
1291 #[error("JWT target-acceptance dispatched work appears after the ordered runtime stopped")]
1295 DispatchAfterStop {
1296 role: JwtTargetAcceptanceLegRole,
1298 },
1299 #[error("JWT target-acceptance request count exceeds its limit")]
1301 RequestCountExceeded,
1302 #[error("JWT target-acceptance active request count exceeds its limit")]
1304 ActiveRequestCountExceeded,
1305 #[error("JWT target-acceptance total response bytes exceed their limit")]
1307 TotalResponseBytesExceeded,
1308 #[error("JWT target-acceptance transport response-byte accounting overflowed")]
1310 TransportResponseBytesOverflow,
1311 #[error("JWT target-acceptance evidence references must be unique")]
1313 DuplicateEvidenceReference,
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318 use super::*;
1319
1320 const PRIVATE_FIELD: &str = "private_acceptance_marker";
1321
1322 fn policy() -> JwtTargetAcceptancePolicy {
1323 JwtTargetAcceptancePolicy::new(
1324 ("operator-policy", "revision-1"),
1325 Url::parse("https://example.test/app/").unwrap(),
1326 Url::parse("https://example.test/app/profile").unwrap(),
1327 "profile-resource",
1328 PRIVATE_FIELD,
1329 )
1330 .unwrap()
1331 }
1332
1333 fn complete(
1334 role: JwtTargetAcceptanceLegRole,
1335 marker: JwtTargetAcceptanceMarkerStatus,
1336 bytes: u64,
1337 evidence_ordinal: u8,
1338 ) -> JwtTargetAcceptanceLegFact {
1339 JwtTargetAcceptanceLegFact::new(
1340 role,
1341 JwtTargetAcceptanceDispatchStatus::Dispatched,
1342 JwtTargetAcceptanceCommitStatus::Committed,
1343 JwtTargetAcceptanceResponseStatus::CompleteAndClassified,
1344 marker,
1345 bytes,
1346 Some(format!("evidence-{evidence_ordinal:02}")),
1347 )
1348 .unwrap()
1349 }
1350
1351 fn complete_legs(
1352 valid_candidate: JwtTargetAcceptanceMarkerStatus,
1353 anonymous_candidate: JwtTargetAcceptanceMarkerStatus,
1354 invalid_candidate: JwtTargetAcceptanceMarkerStatus,
1355 valid_replay: JwtTargetAcceptanceMarkerStatus,
1356 anonymous_replay: JwtTargetAcceptanceMarkerStatus,
1357 invalid_replay: JwtTargetAcceptanceMarkerStatus,
1358 ) -> Vec<JwtTargetAcceptanceLegFact> {
1359 [
1360 valid_candidate,
1361 anonymous_candidate,
1362 invalid_candidate,
1363 valid_replay,
1364 anonymous_replay,
1365 invalid_replay,
1366 ]
1367 .into_iter()
1368 .enumerate()
1369 .map(|(index, marker)| {
1370 complete(
1371 JwtTargetAcceptanceLegRole::ORDERED[index],
1372 marker,
1373 1024,
1374 u8::try_from(index).unwrap(),
1375 )
1376 })
1377 .collect()
1378 }
1379
1380 #[test]
1381 fn positive_control_relationship_is_strict_and_value_free() {
1382 let audit = JwtTargetAcceptanceAudit::from_legs(
1383 &policy(),
1384 complete_legs(
1385 JwtTargetAcceptanceMarkerStatus::Observed,
1386 JwtTargetAcceptanceMarkerStatus::NotObserved,
1387 JwtTargetAcceptanceMarkerStatus::Observed,
1388 JwtTargetAcceptanceMarkerStatus::Observed,
1389 JwtTargetAcceptanceMarkerStatus::NotObserved,
1390 JwtTargetAcceptanceMarkerStatus::Observed,
1391 ),
1392 )
1393 .unwrap();
1394
1395 assert_eq!(audit.schema(), JWT_TARGET_ACCEPTANCE_AUDIT_SCHEMA);
1396 assert_eq!(audit.policy(), JWT_TARGET_ACCEPTANCE_POLICY_ID);
1397 assert_eq!(
1398 audit.valid_marker(),
1399 JwtTargetAcceptanceDimension::ObservedStable
1400 );
1401 assert_eq!(
1402 audit.anonymous_marker(),
1403 JwtTargetAcceptanceDimension::NotObservedStable
1404 );
1405 assert_eq!(
1406 audit.invalid_marker(),
1407 JwtTargetAcceptanceDimension::ObservedStable
1408 );
1409 assert_eq!(
1410 audit.conclusion(),
1411 JwtTargetAcceptanceConclusion::InvalidSignatureControlMarkerObservedWithAnonymousControl
1412 );
1413 assert_eq!(audit.accounting().dispatched_request_count(), 6);
1414 assert_eq!(audit.accounting().dispatched_passive_request_count(), 4);
1415 assert_eq!(audit.accounting().dispatched_active_request_count(), 2);
1416 assert_eq!(audit.accounting().committed_response_count(), 6);
1417
1418 let serialized = serde_json::to_string(&audit).unwrap();
1419 assert!(!serialized.contains("example.test"));
1420 assert!(!serialized.contains(PRIVATE_FIELD));
1421 }
1422
1423 #[test]
1424 fn stable_anonymous_marker_suppresses_invalid_control_work() {
1425 let roles = JwtTargetAcceptanceLegRole::ORDERED;
1426 let legs = vec![
1427 complete(roles[0], JwtTargetAcceptanceMarkerStatus::Observed, 10, 0),
1428 complete(roles[1], JwtTargetAcceptanceMarkerStatus::Observed, 10, 1),
1429 JwtTargetAcceptanceLegFact::not_dispatched(roles[2]),
1430 complete(roles[3], JwtTargetAcceptanceMarkerStatus::Observed, 10, 3),
1431 complete(roles[4], JwtTargetAcceptanceMarkerStatus::Observed, 10, 4),
1432 JwtTargetAcceptanceLegFact::not_dispatched(roles[5]),
1433 ];
1434 let audit = JwtTargetAcceptanceAudit::from_legs(&policy(), legs).unwrap();
1435
1436 assert_eq!(
1437 audit.anonymous_marker(),
1438 JwtTargetAcceptanceDimension::ObservedStable
1439 );
1440 assert_eq!(
1441 audit.invalid_marker(),
1442 JwtTargetAcceptanceDimension::NotEvaluated
1443 );
1444 assert_eq!(
1445 audit.conclusion(),
1446 JwtTargetAcceptanceConclusion::PublicOrTokenAgnosticMarkerObserved
1447 );
1448 assert_eq!(audit.accounting().dispatched_request_count(), 4);
1449 assert_eq!(audit.accounting().dispatched_active_request_count(), 0);
1450 }
1451
1452 #[test]
1453 fn stable_invalid_control_rejection_is_reported_narrowly() {
1454 let audit = JwtTargetAcceptanceAudit::from_legs(
1455 &policy(),
1456 complete_legs(
1457 JwtTargetAcceptanceMarkerStatus::Observed,
1458 JwtTargetAcceptanceMarkerStatus::NotObserved,
1459 JwtTargetAcceptanceMarkerStatus::NotObserved,
1460 JwtTargetAcceptanceMarkerStatus::Observed,
1461 JwtTargetAcceptanceMarkerStatus::NotObserved,
1462 JwtTargetAcceptanceMarkerStatus::NotObserved,
1463 ),
1464 )
1465 .unwrap();
1466 assert_eq!(
1467 audit.conclusion(),
1468 JwtTargetAcceptanceConclusion::InvalidControlMarkerNotObserved
1469 );
1470 }
1471
1472 #[test]
1473 fn replay_disagreement_is_unstable() {
1474 let roles = JwtTargetAcceptanceLegRole::ORDERED;
1475 let legs = vec![
1476 complete(roles[0], JwtTargetAcceptanceMarkerStatus::Observed, 10, 0),
1477 complete(
1478 roles[1],
1479 JwtTargetAcceptanceMarkerStatus::NotObserved,
1480 10,
1481 1,
1482 ),
1483 complete(roles[2], JwtTargetAcceptanceMarkerStatus::Observed, 10, 2),
1484 complete(
1485 roles[3],
1486 JwtTargetAcceptanceMarkerStatus::NotObserved,
1487 10,
1488 3,
1489 ),
1490 complete(
1491 roles[4],
1492 JwtTargetAcceptanceMarkerStatus::NotObserved,
1493 10,
1494 4,
1495 ),
1496 JwtTargetAcceptanceLegFact::not_dispatched(roles[5]),
1497 ];
1498 let audit = JwtTargetAcceptanceAudit::from_legs(&policy(), legs).unwrap();
1499 assert_eq!(audit.valid_marker(), JwtTargetAcceptanceDimension::Unstable);
1500 assert_eq!(
1501 audit.conclusion(),
1502 JwtTargetAcceptanceConclusion::UnstableInconclusive
1503 );
1504 }
1505
1506 #[test]
1507 fn incomplete_and_not_eligible_states_are_typed() {
1508 let roles = JwtTargetAcceptanceLegRole::ORDERED;
1509 let mut legs: Vec<_> = roles
1510 .into_iter()
1511 .map(JwtTargetAcceptanceLegFact::not_dispatched)
1512 .collect();
1513 legs[0] = JwtTargetAcceptanceLegFact::new(
1514 roles[0],
1515 JwtTargetAcceptanceDispatchStatus::Dispatched,
1516 JwtTargetAcceptanceCommitStatus::NotCommitted,
1517 JwtTargetAcceptanceResponseStatus::Incomplete,
1518 JwtTargetAcceptanceMarkerStatus::NotEvaluated,
1519 19,
1520 None,
1521 )
1522 .unwrap();
1523 let audit = JwtTargetAcceptanceAudit::from_legs(&policy(), legs).unwrap();
1524 assert_eq!(
1525 audit.conclusion(),
1526 JwtTargetAcceptanceConclusion::Incomplete(
1527 JwtTargetAcceptanceIncompleteReason::LegNotCommitted {
1528 role: JwtTargetAcceptanceLegRole::ValidCandidate,
1529 }
1530 )
1531 );
1532
1533 let skipped = JwtTargetAcceptanceAudit::not_eligible(
1534 &policy(),
1535 JwtTargetAcceptanceNotEligibleReason::LocalSignatureNotEstablished,
1536 );
1537 assert!(skipped.legs().is_empty());
1538 assert_eq!(skipped.accounting().dispatched_request_count(), 0);
1539 assert_eq!(
1540 skipped.conclusion(),
1541 JwtTargetAcceptanceConclusion::Incomplete(
1542 JwtTargetAcceptanceIncompleteReason::NotEligible(
1543 JwtTargetAcceptanceNotEligibleReason::LocalSignatureNotEstablished
1544 )
1545 )
1546 );
1547 }
1548
1549 #[test]
1550 fn policy_enforces_segment_boundaries_and_exact_origin() {
1551 let application = Url::parse("https://example.test:443/app/").unwrap();
1552 let accepted = [
1553 "https://example.test/app",
1554 "https://example.test/app/",
1555 "https://example.test/app/profile",
1556 ];
1557 for resource in accepted {
1558 JwtTargetAcceptancePolicy::new(
1559 ("operator-policy", "revision-1"),
1560 application.clone(),
1561 Url::parse(resource).unwrap(),
1562 "resource-1",
1563 "accepted",
1564 )
1565 .unwrap();
1566 }
1567
1568 for resource in [
1569 "https://example.test/application/profile",
1570 "https://example.test/app2/profile",
1571 ] {
1572 assert_eq!(
1573 JwtTargetAcceptancePolicy::new(
1574 ("operator-policy", "revision-1"),
1575 application.clone(),
1576 Url::parse(resource).unwrap(),
1577 "resource-1",
1578 "accepted",
1579 )
1580 .unwrap_err(),
1581 JwtTargetAcceptancePolicyError::ResourceOutsideApplication
1582 );
1583 }
1584
1585 for resource in [
1586 "http://example.test/app/profile",
1587 "https://other.test/app/profile",
1588 "https://example.test:444/app/profile",
1589 ] {
1590 assert_eq!(
1591 JwtTargetAcceptancePolicy::new(
1592 ("operator-policy", "revision-1"),
1593 application.clone(),
1594 Url::parse(resource).unwrap(),
1595 "resource-1",
1596 "accepted",
1597 )
1598 .unwrap_err(),
1599 JwtTargetAcceptancePolicyError::ForeignResourceOrigin
1600 );
1601 }
1602 }
1603
1604 #[test]
1605 fn policy_rejects_query_fragment_userinfo_and_unsafe_slugs() {
1606 let application = Url::parse("https://example.test/app/").unwrap();
1607 for resource in [
1608 "https://example.test/app/profile?view=1",
1609 "https://example.test/app/profile#marker",
1610 "https://user@example.test/app/profile",
1611 ] {
1612 assert_eq!(
1613 JwtTargetAcceptancePolicy::new(
1614 ("operator-policy", "revision-1"),
1615 application.clone(),
1616 Url::parse(resource).unwrap(),
1617 "resource-1",
1618 "accepted",
1619 )
1620 .unwrap_err(),
1621 JwtTargetAcceptancePolicyError::InvalidResource
1622 );
1623 }
1624
1625 for identity in [("Uppercase", "revision-1"), ("operator", "bad revision")] {
1626 assert!(JwtTargetAcceptancePolicy::new(
1627 identity,
1628 application.clone(),
1629 Url::parse("https://example.test/app/profile").unwrap(),
1630 "resource-1",
1631 "accepted",
1632 )
1633 .is_err());
1634 }
1635 assert_eq!(
1636 JwtTargetAcceptancePolicy::new(
1637 ("operator", "revision-1"),
1638 application.clone(),
1639 Url::parse("https://example.test/app/profile").unwrap(),
1640 "bad resource",
1641 "accepted",
1642 )
1643 .unwrap_err(),
1644 JwtTargetAcceptancePolicyError::InvalidResourceReference
1645 );
1646 assert_eq!(
1647 JwtTargetAcceptancePolicy::new(
1648 ("operator", "revision-1"),
1649 application,
1650 Url::parse("https://example.test/app/profile").unwrap(),
1651 "resource-1",
1652 "bad field!",
1653 )
1654 .unwrap_err(),
1655 JwtTargetAcceptancePolicyError::InvalidSuccessJsonField
1656 );
1657 }
1658
1659 #[test]
1660 fn policy_accepts_http_without_minting_transport_authority() {
1661 let policy = JwtTargetAcceptancePolicy::new(
1662 ("operator", "revision-1"),
1663 Url::parse("http://127.0.0.1:8080/app/").unwrap(),
1664 Url::parse("http://127.0.0.1:8080/app/profile").unwrap(),
1665 "resource-1",
1666 "accepted",
1667 )
1668 .unwrap();
1669 assert_eq!(policy.method(), JwtTargetAcceptanceMethod::Get);
1670 assert_eq!(policy.application().scheme(), "http");
1671
1672 let rendered = format!("{policy:?}");
1673 assert!(!rendered.contains("127.0.0.1"));
1674 assert!(!rendered.contains("accepted"));
1675 }
1676
1677 #[test]
1678 fn role_contract_is_closed_and_ordered() {
1679 for (index, role) in JwtTargetAcceptanceLegRole::ORDERED.into_iter().enumerate() {
1680 assert_eq!(
1681 role.stage(),
1682 if index < 3 {
1683 JwtTargetAcceptanceLegStage::Candidate
1684 } else {
1685 JwtTargetAcceptanceLegStage::Replay
1686 }
1687 );
1688 assert_eq!(role.requires_authorization(), !role.is_anonymous());
1689 assert_eq!(role.is_active(), role.uses_invalid_token());
1690 assert_eq!(
1691 (
1692 role.uses_valid_token(),
1693 role.uses_invalid_token(),
1694 role.is_anonymous(),
1695 ),
1696 match index % 3 {
1697 0 => (true, false, false),
1698 1 => (false, false, true),
1699 _ => (false, true, false),
1700 }
1701 );
1702 }
1703 assert_eq!(
1704 JwtTargetAcceptanceLegRole::InvalidCandidate.action_id(),
1705 JWT_TARGET_ACCEPTANCE_INVALID_CONTROL_ACTION_ID
1706 );
1707 }
1708
1709 #[test]
1710 fn fact_constructor_rejects_inconsistent_states_and_oversized_bytes() {
1711 assert_eq!(
1712 JwtTargetAcceptanceLegFact::new(
1713 JwtTargetAcceptanceLegRole::ValidCandidate,
1714 JwtTargetAcceptanceDispatchStatus::NotDispatched,
1715 JwtTargetAcceptanceCommitStatus::Committed,
1716 JwtTargetAcceptanceResponseStatus::CompleteAndClassified,
1717 JwtTargetAcceptanceMarkerStatus::Observed,
1718 1,
1719 Some("evidence-1".to_owned()),
1720 )
1721 .unwrap_err(),
1722 JwtTargetAcceptanceLegFactError::InconsistentState
1723 );
1724 assert_eq!(
1725 JwtTargetAcceptanceLegFact::new(
1726 JwtTargetAcceptanceLegRole::ValidCandidate,
1727 JwtTargetAcceptanceDispatchStatus::Dispatched,
1728 JwtTargetAcceptanceCommitStatus::Committed,
1729 JwtTargetAcceptanceResponseStatus::CompleteAndClassified,
1730 JwtTargetAcceptanceMarkerStatus::NotEvaluated,
1731 1,
1732 Some("evidence-1".to_owned()),
1733 )
1734 .unwrap_err(),
1735 JwtTargetAcceptanceLegFactError::InconsistentState
1736 );
1737 assert_eq!(
1738 JwtTargetAcceptanceLegFact::new(
1739 JwtTargetAcceptanceLegRole::ValidCandidate,
1740 JwtTargetAcceptanceDispatchStatus::Dispatched,
1741 JwtTargetAcceptanceCommitStatus::NotCommitted,
1742 JwtTargetAcceptanceResponseStatus::Incomplete,
1743 JwtTargetAcceptanceMarkerStatus::NotEvaluated,
1744 MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES + 1,
1745 None,
1746 )
1747 .unwrap_err(),
1748 JwtTargetAcceptanceLegFactError::ResponseBytesExceeded
1749 );
1750 assert_eq!(
1751 JwtTargetAcceptanceLegFact::new_with_transport_accounting(
1752 JwtTargetAcceptanceLegRole::ValidCandidate,
1753 JwtTargetAcceptanceDispatchStatus::Dispatched,
1754 JwtTargetAcceptanceCommitStatus::NotCommitted,
1755 JwtTargetAcceptanceResponseStatus::Incomplete,
1756 JwtTargetAcceptanceMarkerStatus::NotEvaluated,
1757 2,
1758 1,
1759 None,
1760 )
1761 .unwrap_err(),
1762 JwtTargetAcceptanceLegFactError::TransportBytesBelowRetained
1763 );
1764 }
1765
1766 #[test]
1767 fn aggregate_rejects_count_role_byte_and_evidence_mutations() {
1768 let markers = || {
1769 complete_legs(
1770 JwtTargetAcceptanceMarkerStatus::Observed,
1771 JwtTargetAcceptanceMarkerStatus::NotObserved,
1772 JwtTargetAcceptanceMarkerStatus::Observed,
1773 JwtTargetAcceptanceMarkerStatus::Observed,
1774 JwtTargetAcceptanceMarkerStatus::NotObserved,
1775 JwtTargetAcceptanceMarkerStatus::Observed,
1776 )
1777 };
1778
1779 let mut missing = markers();
1780 missing.pop();
1781 assert_eq!(
1782 JwtTargetAcceptanceAudit::from_legs(&policy(), missing).unwrap_err(),
1783 JwtTargetAcceptanceAuditError::UnexpectedLegCount
1784 );
1785
1786 let mut extra = markers();
1787 extra.push(JwtTargetAcceptanceLegFact::not_dispatched(
1788 JwtTargetAcceptanceLegRole::InvalidReplay,
1789 ));
1790 assert_eq!(
1791 JwtTargetAcceptanceAudit::from_legs(&policy(), extra).unwrap_err(),
1792 JwtTargetAcceptanceAuditError::UnexpectedLegCount
1793 );
1794
1795 let mut wrong_role = markers();
1796 wrong_role[1].role = JwtTargetAcceptanceLegRole::ValidCandidate;
1797 assert_eq!(
1798 JwtTargetAcceptanceAudit::from_legs(&policy(), wrong_role).unwrap_err(),
1799 JwtTargetAcceptanceAuditError::UnexpectedRoleOrder { ordinal: 1 }
1800 );
1801
1802 let mut too_many_bytes = markers();
1803 for fact in &mut too_many_bytes {
1804 fact.retained_response_bytes = 45 * 1024;
1805 fact.accounted_response_bytes = 45 * 1024;
1806 }
1807 assert_eq!(
1808 JwtTargetAcceptanceAudit::from_legs(&policy(), too_many_bytes).unwrap_err(),
1809 JwtTargetAcceptanceAuditError::TotalResponseBytesExceeded
1810 );
1811
1812 let mut transport_overrun = markers();
1813 transport_overrun[0].accounted_response_bytes =
1814 transport_overrun[0].retained_response_bytes + 8 * 1024;
1815 let transport_overrun =
1816 JwtTargetAcceptanceAudit::from_legs(&policy(), transport_overrun).unwrap();
1817 assert_eq!(
1821 transport_overrun.accounting().retained_response_bytes(),
1822 6 * 1024
1823 );
1824 assert_eq!(
1825 transport_overrun.accounting().accounted_response_bytes(),
1826 (6 * 1024) + (8 * 1024)
1827 );
1828
1829 let mut duplicate_evidence = markers();
1830 duplicate_evidence[5].evidence_reference = duplicate_evidence[2].evidence_reference.clone();
1831 assert_eq!(
1832 JwtTargetAcceptanceAudit::from_legs(&policy(), duplicate_evidence).unwrap_err(),
1833 JwtTargetAcceptanceAuditError::DuplicateEvidenceReference
1834 );
1835 }
1836
1837 #[test]
1838 fn aggregate_revalidates_mutated_facts_and_active_prerequisites() {
1839 let roles = JwtTargetAcceptanceLegRole::ORDERED;
1840 let mut invalid_fact = JwtTargetAcceptanceLegFact::not_dispatched(roles[0]);
1841 invalid_fact.accounted_response_bytes = 1;
1842 let mut legs: Vec<_> = roles
1843 .into_iter()
1844 .map(JwtTargetAcceptanceLegFact::not_dispatched)
1845 .collect();
1846 legs[0] = invalid_fact;
1847 assert_eq!(
1848 JwtTargetAcceptanceAudit::from_legs(&policy(), legs).unwrap_err(),
1849 JwtTargetAcceptanceAuditError::InvalidLeg {
1850 role: JwtTargetAcceptanceLegRole::ValidCandidate,
1851 reason: JwtTargetAcceptanceLegFactError::InconsistentState,
1852 }
1853 );
1854
1855 let mut public = vec![
1856 complete(roles[0], JwtTargetAcceptanceMarkerStatus::Observed, 10, 0),
1857 complete(roles[1], JwtTargetAcceptanceMarkerStatus::Observed, 10, 1),
1858 complete(roles[2], JwtTargetAcceptanceMarkerStatus::Observed, 10, 2),
1859 complete(roles[3], JwtTargetAcceptanceMarkerStatus::Observed, 10, 3),
1860 complete(roles[4], JwtTargetAcceptanceMarkerStatus::Observed, 10, 4),
1861 JwtTargetAcceptanceLegFact::not_dispatched(roles[5]),
1862 ];
1863 assert_eq!(
1864 JwtTargetAcceptanceAudit::from_legs(&policy(), public.clone()).unwrap_err(),
1865 JwtTargetAcceptanceAuditError::ActiveLegPrerequisiteNotEstablished {
1866 role: JwtTargetAcceptanceLegRole::InvalidCandidate,
1867 }
1868 );
1869 public[2] = JwtTargetAcceptanceLegFact::not_dispatched(roles[2]);
1870 JwtTargetAcceptanceAudit::from_legs(&policy(), public).unwrap();
1871 }
1872
1873 #[test]
1874 fn aggregate_rejects_dispatch_after_ordered_stop_boundaries() {
1875 let roles = JwtTargetAcceptanceLegRole::ORDERED;
1876 let uncommitted = JwtTargetAcceptanceLegFact::new(
1877 roles[0],
1878 JwtTargetAcceptanceDispatchStatus::Dispatched,
1879 JwtTargetAcceptanceCommitStatus::NotCommitted,
1880 JwtTargetAcceptanceResponseStatus::Incomplete,
1881 JwtTargetAcceptanceMarkerStatus::NotEvaluated,
1882 1,
1883 None,
1884 )
1885 .unwrap();
1886 let mut after_uncommitted = roles
1887 .into_iter()
1888 .map(JwtTargetAcceptanceLegFact::not_dispatched)
1889 .collect::<Vec<_>>();
1890 after_uncommitted[0] = uncommitted;
1891 after_uncommitted[1] =
1892 complete(roles[1], JwtTargetAcceptanceMarkerStatus::NotObserved, 1, 1);
1893 assert_eq!(
1894 JwtTargetAcceptanceAudit::from_legs(&policy(), after_uncommitted).unwrap_err(),
1895 JwtTargetAcceptanceAuditError::DispatchAfterStop { role: roles[1] }
1896 );
1897
1898 let mut after_passive_skip = roles
1899 .into_iter()
1900 .map(JwtTargetAcceptanceLegFact::not_dispatched)
1901 .collect::<Vec<_>>();
1902 after_passive_skip[1] =
1903 complete(roles[1], JwtTargetAcceptanceMarkerStatus::NotObserved, 1, 1);
1904 assert_eq!(
1905 JwtTargetAcceptanceAudit::from_legs(&policy(), after_passive_skip).unwrap_err(),
1906 JwtTargetAcceptanceAuditError::DispatchAfterStop { role: roles[1] }
1907 );
1908
1909 let mut after_byte_limit = vec![
1910 complete(
1911 roles[0],
1912 JwtTargetAcceptanceMarkerStatus::Observed,
1913 MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES,
1914 0,
1915 ),
1916 complete(
1917 roles[1],
1918 JwtTargetAcceptanceMarkerStatus::NotObserved,
1919 MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES,
1920 1,
1921 ),
1922 complete(
1923 roles[2],
1924 JwtTargetAcceptanceMarkerStatus::Observed,
1925 MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES,
1926 2,
1927 ),
1928 complete(
1929 roles[3],
1930 JwtTargetAcceptanceMarkerStatus::Observed,
1931 MAX_JWT_TARGET_ACCEPTANCE_RESPONSE_BYTES,
1932 3,
1933 ),
1934 complete(roles[4], JwtTargetAcceptanceMarkerStatus::NotObserved, 0, 4),
1935 JwtTargetAcceptanceLegFact::not_dispatched(roles[5]),
1936 ];
1937 assert_eq!(
1938 JwtTargetAcceptanceAudit::from_legs(&policy(), after_byte_limit.clone()).unwrap_err(),
1939 JwtTargetAcceptanceAuditError::DispatchAfterStop { role: roles[4] }
1940 );
1941
1942 after_byte_limit[4] = JwtTargetAcceptanceLegFact::not_dispatched(roles[4]);
1943 JwtTargetAcceptanceAudit::from_legs(&policy(), after_byte_limit)
1944 .expect("a frozen all-not-dispatched tail remains valid");
1945 }
1946
1947 #[test]
1948 fn evidence_reference_is_bounded_opaque_ascii_and_unique() {
1949 assert_eq!(
1950 JwtTargetAcceptanceLegFact::new(
1951 JwtTargetAcceptanceLegRole::ValidCandidate,
1952 JwtTargetAcceptanceDispatchStatus::Dispatched,
1953 JwtTargetAcceptanceCommitStatus::Committed,
1954 JwtTargetAcceptanceResponseStatus::CompleteAndClassified,
1955 JwtTargetAcceptanceMarkerStatus::Observed,
1956 1,
1957 Some("contains space".to_owned()),
1958 )
1959 .unwrap_err(),
1960 JwtTargetAcceptanceLegFactError::InvalidEvidenceReference
1961 );
1962 assert_eq!(
1963 JwtTargetAcceptanceLegFact::new(
1964 JwtTargetAcceptanceLegRole::ValidCandidate,
1965 JwtTargetAcceptanceDispatchStatus::Dispatched,
1966 JwtTargetAcceptanceCommitStatus::Committed,
1967 JwtTargetAcceptanceResponseStatus::CompleteAndClassified,
1968 JwtTargetAcceptanceMarkerStatus::Observed,
1969 1,
1970 Some("x".repeat(MAX_JWT_TARGET_ACCEPTANCE_EVIDENCE_REFERENCE_BYTES + 1)),
1971 )
1972 .unwrap_err(),
1973 JwtTargetAcceptanceLegFactError::InvalidEvidenceReference
1974 );
1975 }
1976}