-
Notifications
You must be signed in to change notification settings - Fork 0
feat(sensitive): record credential-free handle lifecycle evidence #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
b6b5b27
test(sensitive): require credential-free handle lifecycle evidence
seonghobae 1904c43
style(sensitive): apply canonical Rust formatting
seonghobae 9170cd3
feat(sensitive): add credential-free handle lifecycle evidence
seonghobae db61f92
feat(sensitive): export handle lifecycle evidence
seonghobae b19576f
test(sensitive): cover every handle identifier validation branch
seonghobae 7936574
style(sensitive): apply rustfmt import ordering
seonghobae ed8fe5f
test(sensitive): satisfy no-panic lint contract
seonghobae dc1ebd2
test(sensitive): return Result on valid evidence paths
seonghobae e155bbd
style(sensitive): apply exact rustfmt function wrapping
seonghobae ca53a69
refactor(sensitive): share identifier validation
seonghobae 0f07fea
refactor(sensitive): reuse shared evidence identifier validator
seonghobae bf20f31
Merge branch 'main' into feat/sensitive-handle-lifecycle-evidence
opencode-agent[bot] 6e615ec
docs(evidence): document sensitive identifier contract
seonghobae dbd2342
Merge branch 'main' into feat/sensitive-handle-lifecycle-evidence
seonghobae efb260b
test(sensitive): reject revocation at exclusive expiry
seonghobae 80ea6c4
fix(sensitive): keep revocation inside exclusive lifetime
seonghobae 38a185b
docs(sensitive): record lifecycle evidence contract
seonghobae 4c23006
merge(main): converge sensitive handle lifecycle onto protected main
seonghobae e5491c7
test(evidence): require handle lifecycle access binding
seonghobae 8f6dd06
style(evidence): format handle access binding regression
seonghobae 8926a5d
fix(evidence): bind handle lifecycle to access authority
seonghobae 2756617
test(evidence): exercise exact handle authority binding
seonghobae 4f5c248
test(evidence): align lifecycle tests with access receipt
seonghobae 7f7dffb
style(evidence): apply canonical rustfmt
seonghobae 7e92b48
style(evidence): finish canonical rustfmt
seonghobae 9303e8f
docs(evidence): record handle authority binding
seonghobae 6643516
test(sensitive): cover revocation at exact expiry
seonghobae 604ed74
fix(sensitive): retain revocation at expiry boundary
seonghobae 0c071a8
test(sensitive): bind handle expiry to retention deadline
seonghobae 4b1a99a
fix(sensitive): cap handle lifetime at retention deadline
seonghobae a34f59d
Merge branch 'main' into feat/sensitive-handle-lifecycle-evidence
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
144 changes: 144 additions & 0 deletions
144
crates/originweave-evidence/src/sensitive_handle_lifecycle.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| //! Credential-free lifecycle evidence for opaque sensitive-value handles. | ||
| //! | ||
| //! A trusted broker can use this value object to record when a handle was | ||
| //! issued, when it expires, how many uses it permits, how many resolutions were | ||
| //! observed, and when it was revoked. The lifecycle retains the complete | ||
| //! credential-free sensitive-access receipt that authorized opaque-handle use, | ||
| //! while intentionally excluding the opaque handle token and protected value. | ||
|
|
||
| use crate::sensitive_access::{ | ||
| SensitiveAccessEvidence, SensitiveAccessOutcome, SensitiveEvidenceError, | ||
| }; | ||
|
|
||
| /// Unvalidated metadata describing one opaque sensitive-value handle lifecycle. | ||
| /// | ||
| /// The embedded access receipt binds the lifecycle to the tenant, actor, task, | ||
| /// field set, purpose, destination, classification, policy version, and exact | ||
| /// opaque-handle authorization without carrying protected values. When the access | ||
| /// receipt carries a retention deadline, the handle must expire no later than | ||
| /// that deadline so derived opaque authority cannot outlive its governing receipt. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub struct SensitiveHandleLifecycleEvidenceInput { | ||
| /// Credential-free access receipt that authorized this opaque handle. | ||
| pub access_evidence: SensitiveAccessEvidence, | ||
| /// Trusted Unix epoch second when the handle was issued. | ||
| pub issued_epoch_seconds: u64, | ||
| /// Trusted Unix epoch second after which the handle is no longer valid. | ||
| /// | ||
| /// When the retained access receipt defines a retention deadline, this value | ||
| /// may equal but must not exceed that deadline. | ||
| pub expires_epoch_seconds: u64, | ||
| /// Maximum number of broker resolutions authorized for the handle. | ||
| pub maximum_uses: u32, | ||
| /// Number of broker resolutions already observed for the handle. | ||
| pub resolution_count: u32, | ||
| /// Trusted Unix epoch second when the handle was revoked, when applicable. | ||
| /// | ||
| /// A revocation recorded exactly at expiry is retained as a terminal audit | ||
| /// event even though it cannot extend or restore handle validity. | ||
| pub revoked_epoch_seconds: Option<u64>, | ||
| } | ||
|
|
||
| /// Immutable credential-free evidence about one opaque handle lifecycle. | ||
| /// | ||
| /// The value retains the exact credential-free sensitive-access receipt that | ||
| /// authorized opaque-handle use, but deliberately excludes both the opaque | ||
| /// handle token and the secret or protected value that the broker can resolve. | ||
| /// Any receipt retention deadline also bounds the derived handle lifetime. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub struct SensitiveHandleLifecycleEvidence { | ||
| access_evidence: SensitiveAccessEvidence, | ||
| issued_epoch_seconds: u64, | ||
| expires_epoch_seconds: u64, | ||
| maximum_uses: u32, | ||
| resolution_count: u32, | ||
| revoked_epoch_seconds: Option<u64>, | ||
| } | ||
|
|
||
| impl TryFrom<SensitiveHandleLifecycleEvidenceInput> for SensitiveHandleLifecycleEvidence { | ||
| type Error = SensitiveEvidenceError; | ||
|
|
||
| fn try_from(input: SensitiveHandleLifecycleEvidenceInput) -> Result<Self, Self::Error> { | ||
| if input.access_evidence.outcome() != SensitiveAccessOutcome::OpaqueHandleOnly | ||
| || input.issued_epoch_seconds == 0 | ||
| || input.issued_epoch_seconds < input.access_evidence.decision_epoch_seconds() | ||
| || input.expires_epoch_seconds <= input.issued_epoch_seconds | ||
| || input | ||
| .access_evidence | ||
| .retention_deadline_epoch_seconds() | ||
| .is_some_and(|deadline| input.expires_epoch_seconds > deadline) | ||
| || input.maximum_uses == 0 | ||
| || input.resolution_count > input.maximum_uses | ||
| || input.revoked_epoch_seconds.is_some_and(|revoked| { | ||
| revoked < input.issued_epoch_seconds || revoked > input.expires_epoch_seconds | ||
| }) | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
seonghobae marked this conversation as resolved.
|
||
| { | ||
| return Err(SensitiveEvidenceError::InvalidLifecycle); | ||
| } | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
|
|
||
| Ok(Self { | ||
| access_evidence: input.access_evidence, | ||
| issued_epoch_seconds: input.issued_epoch_seconds, | ||
| expires_epoch_seconds: input.expires_epoch_seconds, | ||
| maximum_uses: input.maximum_uses, | ||
| resolution_count: input.resolution_count, | ||
| revoked_epoch_seconds: input.revoked_epoch_seconds, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| impl SensitiveHandleLifecycleEvidence { | ||
| /// Return the credential-free access receipt that authorized this opaque handle. | ||
| #[must_use] | ||
| pub const fn access_evidence(&self) -> &SensitiveAccessEvidence { | ||
| &self.access_evidence | ||
| } | ||
|
|
||
| /// Return the originating sensitive-data access request identifier. | ||
| #[must_use] | ||
| pub fn request_id(&self) -> &str { | ||
| self.access_evidence.request_id() | ||
| } | ||
|
|
||
| /// Return the policy decision identifier associated with the handle. | ||
| #[must_use] | ||
| pub fn decision_id(&self) -> &str { | ||
| self.access_evidence.decision_id() | ||
| } | ||
|
|
||
| /// Return the trusted handle issuance time as a Unix epoch second. | ||
| #[must_use] | ||
| pub const fn issued_epoch_seconds(&self) -> u64 { | ||
| self.issued_epoch_seconds | ||
| } | ||
|
|
||
| /// Return the trusted handle expiry time as a Unix epoch second. | ||
| #[must_use] | ||
| pub const fn expires_epoch_seconds(&self) -> u64 { | ||
| self.expires_epoch_seconds | ||
| } | ||
|
|
||
| /// Return the maximum number of broker resolutions authorized for the handle. | ||
| #[must_use] | ||
| pub const fn maximum_uses(&self) -> u32 { | ||
| self.maximum_uses | ||
| } | ||
|
|
||
| /// Return the number of broker resolutions already observed for the handle. | ||
| #[must_use] | ||
| pub const fn resolution_count(&self) -> u32 { | ||
| self.resolution_count | ||
| } | ||
|
|
||
| /// Return the trusted revocation time when the handle has been revoked. | ||
| #[must_use] | ||
| pub const fn revoked_epoch_seconds(&self) -> Option<u64> { | ||
| self.revoked_epoch_seconds | ||
| } | ||
|
|
||
| /// Return whether trusted evidence records that this handle was revoked. | ||
| #[must_use] | ||
| pub const fn is_revoked(&self) -> bool { | ||
| self.revoked_epoch_seconds.is_some() | ||
| } | ||
| } | ||
114 changes: 114 additions & 0 deletions
114
crates/originweave-evidence/tests/sensitive_handle_access_binding.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| use originweave_core::Origin; | ||
| use originweave_evidence::{ | ||
| SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, | ||
| SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, | ||
| SensitiveHandleLifecycleEvidenceInput, | ||
| }; | ||
|
|
||
| type TestResult = Result<(), String>; | ||
|
|
||
| fn access_evidence( | ||
| outcome: SensitiveAccessOutcome, | ||
| decision_epoch_seconds: u64, | ||
| ) -> Result<SensitiveAccessEvidence, String> { | ||
| let destination = | ||
| Origin::parse("https://checkout.example.com").map_err(|error| format!("{error:?}"))?; | ||
| SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { | ||
| request_id: "request-42".to_owned(), | ||
| decision_id: "decision-42".to_owned(), | ||
| tenant_id: "tenant-7".to_owned(), | ||
| actor_id: "workload-browser-adapter".to_owned(), | ||
| task_id: "task-99".to_owned(), | ||
| field_ids: vec!["shipping_name".to_owned(), "shipping_address".to_owned()], | ||
| purpose_id: "fulfill-shipment".to_owned(), | ||
| destination, | ||
| classification: SensitiveAccessClass::PersonalData, | ||
| outcome, | ||
| policy_version: "sensitive-policy-v3".to_owned(), | ||
| approval_reference: None, | ||
| decision_epoch_seconds, | ||
| disclosure_epoch_seconds: None, | ||
| retention_deadline_epoch_seconds: Some(decision_epoch_seconds + 3_600), | ||
| }) | ||
| .map_err(|error| format!("{error:?}")) | ||
| } | ||
|
|
||
| fn lifecycle_input( | ||
| access_evidence: SensitiveAccessEvidence, | ||
| issued_epoch_seconds: u64, | ||
| ) -> SensitiveHandleLifecycleEvidenceInput { | ||
| SensitiveHandleLifecycleEvidenceInput { | ||
| access_evidence, | ||
| issued_epoch_seconds, | ||
| expires_epoch_seconds: issued_epoch_seconds + 300, | ||
| maximum_uses: 2, | ||
| resolution_count: 0, | ||
| revoked_epoch_seconds: None, | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> TestResult { | ||
| let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; | ||
| let evidence = | ||
| SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access.clone(), 1_720_000_001)) | ||
| .map_err(|error| format!("{error:?}"))?; | ||
|
|
||
| assert_eq!(evidence.access_evidence(), &access); | ||
| assert_eq!(evidence.request_id(), access.request_id()); | ||
| assert_eq!(evidence.decision_id(), access.decision_id()); | ||
| assert_eq!(evidence.access_evidence().tenant_id(), "tenant-7"); | ||
| assert_eq!(evidence.access_evidence().task_id(), "task-99"); | ||
| assert_eq!( | ||
| evidence.access_evidence().field_ids(), | ||
| ["shipping_name", "shipping_address"] | ||
| ); | ||
| assert_eq!( | ||
| evidence.access_evidence().destination().as_str(), | ||
| "https://checkout.example.com" | ||
| ); | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn lifecycle_rejects_non_opaque_handle_access_decision() -> TestResult { | ||
| let denied = access_evidence(SensitiveAccessOutcome::DenyAccess, 1_720_000_000)?; | ||
|
|
||
| assert_eq!( | ||
| SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(denied, 1_720_000_001)), | ||
| Err(SensitiveEvidenceError::InvalidLifecycle) | ||
| ); | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn lifecycle_rejects_issuance_before_policy_decision() -> TestResult { | ||
| let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_100)?; | ||
|
|
||
| assert_eq!( | ||
| SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access, 1_720_000_099)), | ||
| Err(SensitiveEvidenceError::InvalidLifecycle) | ||
| ); | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn lifecycle_expiry_respects_access_retention_deadline() -> TestResult { | ||
| let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; | ||
| let retention_deadline = access | ||
| .retention_deadline_epoch_seconds() | ||
| .ok_or_else(|| "fixture must carry a retention deadline".to_owned())?; | ||
|
|
||
| let mut exact_deadline = lifecycle_input(access.clone(), 1_720_000_001); | ||
| exact_deadline.expires_epoch_seconds = retention_deadline; | ||
| SensitiveHandleLifecycleEvidence::try_from(exact_deadline) | ||
| .map_err(|error| format!("{error:?}"))?; | ||
|
|
||
| let mut after_deadline = lifecycle_input(access, 1_720_000_001); | ||
| after_deadline.expires_epoch_seconds = retention_deadline + 1; | ||
| assert_eq!( | ||
| SensitiveHandleLifecycleEvidence::try_from(after_deadline), | ||
| Err(SensitiveEvidenceError::InvalidLifecycle) | ||
| ); | ||
| Ok(()) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.