Skip to content
Merged
Show file tree
Hide file tree
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 Aug 10, 2026
1904c43
style(sensitive): apply canonical Rust formatting
seonghobae Aug 10, 2026
9170cd3
feat(sensitive): add credential-free handle lifecycle evidence
seonghobae Aug 10, 2026
db61f92
feat(sensitive): export handle lifecycle evidence
seonghobae Aug 10, 2026
b19576f
test(sensitive): cover every handle identifier validation branch
seonghobae Aug 10, 2026
7936574
style(sensitive): apply rustfmt import ordering
seonghobae Aug 10, 2026
ed8fe5f
test(sensitive): satisfy no-panic lint contract
seonghobae Aug 10, 2026
dc1ebd2
test(sensitive): return Result on valid evidence paths
seonghobae Aug 10, 2026
e155bbd
style(sensitive): apply exact rustfmt function wrapping
seonghobae Aug 10, 2026
ca53a69
refactor(sensitive): share identifier validation
seonghobae Aug 10, 2026
0f07fea
refactor(sensitive): reuse shared evidence identifier validator
seonghobae Aug 10, 2026
bf20f31
Merge branch 'main' into feat/sensitive-handle-lifecycle-evidence
opencode-agent[bot] Aug 13, 2026
6e615ec
docs(evidence): document sensitive identifier contract
seonghobae Aug 13, 2026
dbd2342
Merge branch 'main' into feat/sensitive-handle-lifecycle-evidence
seonghobae Aug 16, 2026
efb260b
test(sensitive): reject revocation at exclusive expiry
seonghobae Aug 20, 2026
80ea6c4
fix(sensitive): keep revocation inside exclusive lifetime
seonghobae Aug 20, 2026
38a185b
docs(sensitive): record lifecycle evidence contract
seonghobae Aug 20, 2026
4c23006
merge(main): converge sensitive handle lifecycle onto protected main
seonghobae Aug 24, 2026
e5491c7
test(evidence): require handle lifecycle access binding
seonghobae Aug 24, 2026
8f6dd06
style(evidence): format handle access binding regression
seonghobae Aug 24, 2026
8926a5d
fix(evidence): bind handle lifecycle to access authority
seonghobae Aug 24, 2026
2756617
test(evidence): exercise exact handle authority binding
seonghobae Aug 24, 2026
4f5c248
test(evidence): align lifecycle tests with access receipt
seonghobae Aug 24, 2026
7f7dffb
style(evidence): apply canonical rustfmt
seonghobae Aug 24, 2026
7e92b48
style(evidence): finish canonical rustfmt
seonghobae Aug 24, 2026
9303e8f
docs(evidence): record handle authority binding
seonghobae Aug 24, 2026
6643516
test(sensitive): cover revocation at exact expiry
seonghobae Aug 24, 2026
604ed74
fix(sensitive): retain revocation at expiry boundary
seonghobae Aug 24, 2026
0c071a8
test(sensitive): bind handle expiry to retention deadline
seonghobae Aug 25, 2026
4b1a99a
fix(sensitive): cap handle lifetime at retention deadline
seonghobae Aug 25, 2026
a34f59d
Merge branch 'main' into feat/sensitive-handle-lifecycle-evidence
seonghobae Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/originweave-evidence/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@
#![deny(missing_docs)]

mod sensitive_access;
mod sensitive_handle_lifecycle;

pub use sensitive_access::{
MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass,
SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome,
SensitiveEvidenceError,
};
pub use sensitive_handle_lifecycle::{
SensitiveHandleLifecycleEvidence, SensitiveHandleLifecycleEvidenceInput,
};

use std::collections::BTreeMap;

Expand Down
2 changes: 1 addition & 1 deletion crates/originweave-evidence/src/sensitive_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ fn validate_fields(field_ids: &[String]) -> Result<(), SensitiveEvidenceError> {
Ok(())
}

fn valid_identifier(value: &str) -> bool {
pub(crate) fn valid_identifier(value: &str) -> bool {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
!value.is_empty()
&& value.len() <= MAX_SENSITIVE_IDENTIFIER_BYTES
&& value.bytes().any(|byte| byte.is_ascii_alphanumeric())
Expand Down
125 changes: 125 additions & 0 deletions crates/originweave-evidence/src/sensitive_handle_lifecycle.rs
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
})
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
{
return Err(SensitiveEvidenceError::InvalidLifecycle);
}
Comment thread
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()
}
}
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)
);
}
}
Loading