Skip to content
Merged
Show file tree
Hide file tree
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 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ All notable changes to OriginWeave are documented in this file. The format follo
- Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves.
- Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior and `NotConfigured` revocation evidence.
- Credential-free TLS evidence containing canonical origin, TCP peers, reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration.
- Credential-free sensitive-handle lifecycle evidence binds issuance, exclusive expiry, bounded uses, observed resolution count, and revocation to the exact credential-free `OpaqueHandleOnly` sensitive-access receipt, preserving tenant, actor, task, field set, purpose, destination, classification, policy version, and decision time without storing opaque handle tokens or protected values.
- Credential-free connection and redirect evidence containing canonical addresses, destination classes, target digests, hop numbers, and approved-address counts.
- Credential-free verified TCP evidence containing the logical origin, requested socket, observed peer, destination class, successful attempt number, and per-attempt timeout.
- Standard `Display` and `std::error::Error` contracts for destination, redirect, digest, direct-network, and TLS failures, including preserved destination-policy, rustls, and operating-system sources where applicable.
Expand Down
4 changes: 4 additions & 0 deletions crates/originweave-evidence/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

mod extraction_schema;
mod sensitive_access;
mod sensitive_handle_lifecycle;

pub use extraction_schema::{
ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchema,
Expand All @@ -20,6 +21,9 @@ pub use sensitive_access::{
SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome,
SensitiveEvidenceError,
};
pub use sensitive_handle_lifecycle::{
SensitiveHandleLifecycleEvidence, SensitiveHandleLifecycleEvidenceInput,
};

use std::collections::BTreeMap;

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

fn valid_identifier(value: &str) -> bool {
/// Return whether `value` is a non-empty identifier of at most
/// `MAX_SENSITIVE_IDENTIFIER_BYTES` ASCII bytes, contains at least one
/// alphanumeric byte, and otherwise uses only `.`, `_`, `:`, or `-` punctuation.
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
144 changes: 144 additions & 0 deletions crates/originweave-evidence/src/sensitive_handle_lifecycle.rs
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
})
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 {
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 crates/originweave-evidence/tests/sensitive_handle_access_binding.rs
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(())
}
Loading
Loading