Repository navigation
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 12 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
125 changes: 125 additions & 0 deletions
125
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,125 @@ | ||
| //! 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 evidence intentionally has no field | ||
| //! for the opaque handle token or the protected value behind that token. | ||
|
|
||
| use crate::sensitive_access::{SensitiveEvidenceError, valid_identifier}; | ||
|
|
||
| /// Unvalidated metadata describing one opaque sensitive-value handle lifecycle. | ||
| /// | ||
| /// This input records correlation identifiers and bounded lifecycle counters | ||
| /// only. It cannot carry the opaque handle token or a protected value. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub struct SensitiveHandleLifecycleEvidenceInput { | ||
| /// Correlation identifier for the sensitive-data access request. | ||
| pub request_id: String, | ||
| /// Identifier for the policy decision that authorized or denied the handle. | ||
| pub decision_id: String, | ||
| /// 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. | ||
| 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. | ||
| pub revoked_epoch_seconds: Option<u64>, | ||
| } | ||
|
|
||
| /// Immutable credential-free evidence about one opaque handle lifecycle. | ||
| /// | ||
| /// The value deliberately excludes both the opaque handle token and the secret | ||
| /// or protected value that the broker can resolve from it. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub struct SensitiveHandleLifecycleEvidence { | ||
| request_id: String, | ||
| decision_id: String, | ||
| 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 !valid_identifier(&input.request_id) || !valid_identifier(&input.decision_id) { | ||
| return Err(SensitiveEvidenceError::InvalidIdentifier); | ||
| } | ||
| if input.issued_epoch_seconds == 0 | ||
| || input.expires_epoch_seconds <= input.issued_epoch_seconds | ||
| || 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 { | ||
| request_id: input.request_id, | ||
| decision_id: input.decision_id, | ||
| 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 originating sensitive-data access request identifier. | ||
| #[must_use] | ||
| pub fn request_id(&self) -> &str { | ||
| &self.request_id | ||
| } | ||
|
|
||
| /// Return the policy decision identifier associated with the handle. | ||
| #[must_use] | ||
| pub fn decision_id(&self) -> &str { | ||
| &self.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() | ||
| } | ||
| } | ||
122 changes: 122 additions & 0 deletions
122
crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.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,122 @@ | ||
| use originweave_evidence::{ | ||
| MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, | ||
| SensitiveHandleLifecycleEvidenceInput, | ||
| }; | ||
|
|
||
| fn valid_input() -> SensitiveHandleLifecycleEvidenceInput { | ||
| SensitiveHandleLifecycleEvidenceInput { | ||
| request_id: "request-42".to_owned(), | ||
| decision_id: "decision-42".to_owned(), | ||
| issued_epoch_seconds: 1_720_000_000, | ||
| expires_epoch_seconds: 1_720_000_300, | ||
| maximum_uses: 2, | ||
| resolution_count: 1, | ||
| revoked_epoch_seconds: None, | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn records_bounded_handle_lifecycle_without_handle_or_secret_material() | ||
| -> Result<(), SensitiveEvidenceError> { | ||
| let evidence = SensitiveHandleLifecycleEvidence::try_from(valid_input())?; | ||
|
|
||
| assert_eq!(evidence.request_id(), "request-42"); | ||
| assert_eq!(evidence.decision_id(), "decision-42"); | ||
| assert_eq!(evidence.issued_epoch_seconds(), 1_720_000_000); | ||
| assert_eq!(evidence.expires_epoch_seconds(), 1_720_000_300); | ||
| assert_eq!(evidence.maximum_uses(), 2); | ||
| assert_eq!(evidence.resolution_count(), 1); | ||
| assert_eq!(evidence.revoked_epoch_seconds(), None); | ||
| assert!(!evidence.is_revoked()); | ||
|
|
||
| let debug = format!("{evidence:?}"); | ||
| assert!(!debug.contains("opaque-handle-token-should-never-be-evidence")); | ||
| assert!(!debug.contains("raw-secret-should-never-be-evidence")); | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn records_revocation_time_without_storing_revocation_payloads() | ||
| -> Result<(), SensitiveEvidenceError> { | ||
| let mut input = valid_input(); | ||
| input.revoked_epoch_seconds = Some(1_720_000_120); | ||
| input.resolution_count = 2; | ||
|
|
||
| let evidence = SensitiveHandleLifecycleEvidence::try_from(input)?; | ||
|
|
||
| assert_eq!(evidence.revoked_epoch_seconds(), Some(1_720_000_120)); | ||
| assert!(evidence.is_revoked()); | ||
| assert_eq!(evidence.resolution_count(), evidence.maximum_uses()); | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn rejects_invalid_request_or_decision_identifiers() { | ||
| let invalid_request_ids = [ | ||
| String::new(), | ||
| "-".repeat(3), | ||
| "bad/request".to_owned(), | ||
| "a".repeat(MAX_SENSITIVE_IDENTIFIER_BYTES + 1), | ||
| ]; | ||
| for request_id in invalid_request_ids { | ||
| let mut input = valid_input(); | ||
| input.request_id = request_id; | ||
| assert_eq!( | ||
| SensitiveHandleLifecycleEvidence::try_from(input), | ||
| Err(SensitiveEvidenceError::InvalidIdentifier) | ||
| ); | ||
| } | ||
|
|
||
| let mut invalid_decision = valid_input(); | ||
| invalid_decision.decision_id = String::new(); | ||
| assert_eq!( | ||
| SensitiveHandleLifecycleEvidence::try_from(invalid_decision), | ||
| Err(SensitiveEvidenceError::InvalidIdentifier) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn rejects_zero_or_non_increasing_handle_lifetime() { | ||
| for (issued, expires) in [ | ||
| (0, 1_720_000_300), | ||
| (1_720_000_300, 1_720_000_300), | ||
| (1_720_000_301, 1_720_000_300), | ||
| ] { | ||
| let mut input = valid_input(); | ||
| input.issued_epoch_seconds = issued; | ||
| input.expires_epoch_seconds = expires; | ||
| assert_eq!( | ||
| SensitiveHandleLifecycleEvidence::try_from(input), | ||
| Err(SensitiveEvidenceError::InvalidLifecycle) | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn rejects_zero_use_limit_or_resolution_count_above_limit() { | ||
| let mut zero_limit = valid_input(); | ||
| zero_limit.maximum_uses = 0; | ||
| assert_eq!( | ||
| SensitiveHandleLifecycleEvidence::try_from(zero_limit), | ||
| Err(SensitiveEvidenceError::InvalidLifecycle) | ||
| ); | ||
|
|
||
| let mut overused = valid_input(); | ||
| overused.resolution_count = overused.maximum_uses + 1; | ||
| assert_eq!( | ||
| SensitiveHandleLifecycleEvidence::try_from(overused), | ||
| Err(SensitiveEvidenceError::InvalidLifecycle) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn rejects_revocation_outside_handle_lifetime() { | ||
| for revoked in [1_719_999_999, 1_720_000_301] { | ||
| let mut input = valid_input(); | ||
| input.revoked_epoch_seconds = Some(revoked); | ||
| assert_eq!( | ||
| SensitiveHandleLifecycleEvidence::try_from(input), | ||
| Err(SensitiveEvidenceError::InvalidLifecycle) | ||
| ); | ||
| } | ||
| } |
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.