Skip to content
92 changes: 61 additions & 31 deletions crates/originweave-policy/src/sensitive_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,14 @@ pub fn evaluate_disclosure(
/// Result of evaluating one attempted use of an opaque sensitive-value handle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HandleUseDecision {
/// The supplied exact scope, classification, expiry, and prior-use count permit broker admission.
/// The supplied exact authority, audience, expiry, and prior-use count permit broker admission.
Authorized,
/// The authoritative in-process handle state was revoked before this use.
Revoked,
/// Tenant, task, field, purpose, destination, or classification did not match the handle scope.
ScopeMismatch,
/// The caller audience was invalid or did not match the handle's non-transferable audience.
AudienceMismatch,
/// The handle is no longer valid at the supplied trusted time.
Expired,
/// The bounded use count has already been consumed.
Expand Down Expand Up @@ -192,23 +194,29 @@ pub enum HandleRevocationReason {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SensitiveValueHandleScope {
authority: SensitiveDataAuthority,
audience_id: String,
expires_at_epoch_seconds: u64,
max_uses: u32,
}

impl SensitiveValueHandleScope {
/// Build an opaque-handle scope with exact authority, exclusive expiry, and bounded use count.
/// Build an opaque-handle scope with exact authority, non-transferable audience,
/// exclusive expiry, and bounded use count.
///
/// A later field reclassification creates a different [`SensitiveDataAuthority`]
/// and therefore requires a newly authorized handle.
/// The audience identifier uses the same bounded ASCII policy-token grammar as
/// other authority identifiers. Invalid audience identifiers remain fail-closed
/// when the scope is evaluated. A later field reclassification or audience
/// change therefore requires a newly authorized handle.
#[must_use]
pub const fn new(
pub fn new(
authority: SensitiveDataAuthority,
audience_id: &str,
expires_at_epoch_seconds: u64,
max_uses: u32,
) -> Self {
Self {
authority,
audience_id: audience_id.to_owned(),
expires_at_epoch_seconds,
max_uses,
}
Expand All @@ -219,24 +227,29 @@ impl SensitiveValueHandleScope {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandleUseRequest {
authority: SensitiveDataAuthority,
audience_id: String,
now_epoch_seconds: u64,
uses_so_far: u32,
}

impl HandleUseRequest {
/// Build a handle-use evaluation request from trusted time and authoritative broker state.
/// Build a handle-use evaluation request from exact authority, caller audience,
/// trusted time, and authoritative broker use state.
///
/// The eventual broker must supply these state values from its own trusted,
/// caller-unforgeable storage; accepting this struct does not make arbitrary
/// caller input authoritative.
/// The eventual broker must derive `audience_id` from authenticated service or
/// workload identity and supply the state values from caller-unforgeable
/// storage. Accepting this value object does not make arbitrary caller input
/// authoritative.
#[must_use]
pub const fn new(
pub fn new(
authority: SensitiveDataAuthority,
audience_id: &str,
now_epoch_seconds: u64,
uses_so_far: u32,
) -> Self {
Self {
authority,
audience_id: audience_id.to_owned(),
now_epoch_seconds,
uses_so_far,
}
Expand All @@ -246,16 +259,18 @@ impl HandleUseRequest {
/// In-process authoritative use-count and revocation state for one opaque sensitive-value handle scope.
///
/// This value removes the caller-supplied prior-use count from the reservation
/// operation. A successful reservation compares the exact authority, trusted
/// time, expiry, revocation state, and current count and then increments the
/// count while the caller holds an exclusive mutable borrow of this state.
/// Denied reservations never consume a use.
/// operation. A successful reservation compares the exact authority, exact
/// non-transferable audience, trusted time, expiry, revocation state, and current
/// count and then increments the count while the caller holds an exclusive mutable
/// borrow of this state. Denied reservations never consume a use.
///
/// This is a policy-state primitive, not the trusted broker itself. It contains
/// neither the opaque handle token nor protected data and provides no durable or
/// cross-process transaction, value resolution, compensation, or persistence. A
/// shared or durable broker must place the state behind its own transactional or
/// locking boundary, persist lifecycle state, and recheck it before disclosure.
/// neither the opaque handle token nor protected data and provides no authenticated
/// workload identity, durable or cross-process transaction, value resolution,
/// compensation, or persistence. A shared or durable broker must derive the
/// audience from authenticated caller identity, place the state behind its own
/// transactional or locking boundary, persist lifecycle state, and recheck it
/// before disclosure.
#[derive(Debug, PartialEq, Eq)]
pub struct SensitiveHandleUseState {
scope: SensitiveValueHandleScope,
Expand Down Expand Up @@ -301,36 +316,46 @@ impl SensitiveHandleUseState {

/// Reserve one use from the current authoritative count when policy permits it.
///
/// The supplied time must come from the trusted broker boundary. Revocation,
/// exact-scope, expiry, and use-limit denial leaves the authoritative count
/// unchanged.
/// The audience must be derived by the trusted broker from authenticated caller
/// identity, and the supplied time must come from the broker's trusted clock.
/// Revocation is authoritative and is checked before later request details so a
/// revoked handle cannot expose whether a different scope, audience, expiry, or
/// use-limit condition would otherwise have matched. Every denial leaves the
/// authoritative count unchanged.
pub fn reserve_use(
&mut self,
authority: SensitiveDataAuthority,
audience_id: &str,
now_epoch_seconds: u64,
) -> HandleUseDecision {
if self.revocation_reason.is_some() {
return HandleUseDecision::Revoked;
}
let request = HandleUseRequest::new(authority, now_epoch_seconds, self.reserved_uses);
let request = HandleUseRequest::new(
authority,
audience_id,
now_epoch_seconds,
self.reserved_uses,
);
let decision = evaluate_handle_use(&request, &self.scope);
if decision == HandleUseDecision::Authorized {
self.reserved_uses += 1;
if decision != HandleUseDecision::Authorized {
return decision;
}
decision
self.reserved_uses += 1;
HandleUseDecision::Authorized
}
}

/// Evaluate whether authoritative broker state is admissible for one handle use.
///
/// This pure function does not consume a use, mutate broker state, resolve a
/// handle, or release a protected value. It is therefore not standalone
/// enforcement. A trusted broker must obtain trusted time and caller-unforgeable
/// handle state, atomically reserve or increment the use count before value
/// resolution, and recheck the reserved authority immediately before disclosure.
/// Missing or malformed authority identifiers fail closed as a scope mismatch.
/// The authority destination must already have crossed the canonical [`Origin`]
/// boundary.
/// enforcement. A trusted broker must obtain authenticated caller audience,
/// trusted time, and caller-unforgeable handle state, atomically reserve or
/// increment the use count before value resolution, and recheck the reserved
/// authority immediately before disclosure. Missing or malformed authority or
/// audience identifiers fail closed. The authority destination must already have
/// crossed the canonical [`Origin`] boundary.
#[must_use]
pub fn evaluate_handle_use(
request: &HandleUseRequest,
Expand All @@ -341,6 +366,11 @@ pub fn evaluate_handle_use(
|| request.authority != scope.authority
{
HandleUseDecision::ScopeMismatch
} else if !authority_identifier_is_valid(&request.audience_id)
|| !authority_identifier_is_valid(&scope.audience_id)
|| request.audience_id != scope.audience_id
{
HandleUseDecision::AudienceMismatch
} else if request.now_epoch_seconds >= scope.expires_at_epoch_seconds {
HandleUseDecision::Expired
} else if request.uses_so_far >= scope.max_uses {
Expand Down
18 changes: 15 additions & 3 deletions crates/originweave-policy/tests/handle_classification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ use originweave_policy::{
SensitiveValueHandleScope, evaluate_handle_use,
};

const AUDIENCE: &str = "trusted_browser_adapter";

fn destination() -> Origin {
Origin::parse("https://shipping.example").expect("canonical destination")
}
Expand All @@ -23,11 +25,21 @@ fn authority(classification: DataClassification) -> SensitiveDataAuthority {

#[test]
fn opaque_handle_use_requires_the_exact_data_classification() {
let scope =
SensitiveValueHandleScope::new(authority(DataClassification::PersonalData), 2_000, 2);
let permitted = HandleUseRequest::new(authority(DataClassification::PersonalData), 1_999, 0);
let scope = SensitiveValueHandleScope::new(
authority(DataClassification::PersonalData),
AUDIENCE,
2_000,
2,
);
let permitted = HandleUseRequest::new(
authority(DataClassification::PersonalData),
AUDIENCE,
1_999,
0,
);
let reclassified = HandleUseRequest::new(
authority(DataClassification::SensitivePersonalData),
AUDIENCE,
1_999,
0,
);
Expand Down
75 changes: 72 additions & 3 deletions crates/originweave-policy/tests/sensitive_data_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const TASK: &str = "task_ship_order";
const FIELD: &str = "shipping_address";
const PURPOSE: &str = "fulfill_order";
const DESTINATION: &str = "https://shipping.example";
const AUDIENCE: &str = "trusted_browser_adapter";

#[derive(Clone, Copy)]
struct AuthorityCase<'a> {
Expand Down Expand Up @@ -79,7 +80,25 @@ fn handle_scope(
authority: AuthorityCase<'_>,
classification: DataClassification,
) -> SensitiveValueHandleScope {
SensitiveValueHandleScope::new(sensitive_authority(authority, classification), 2_000, 2)
SensitiveValueHandleScope::new(
sensitive_authority(authority, classification),
AUDIENCE,
2_000,
2,
)
}

fn handle_scope_for_audience(
authority: AuthorityCase<'_>,
classification: DataClassification,
audience: &str,
) -> SensitiveValueHandleScope {
SensitiveValueHandleScope::new(
sensitive_authority(authority, classification),
audience,
2_000,
2,
)
}

fn handle_use(
Expand All @@ -88,7 +107,22 @@ fn handle_use(
now: u64,
uses: u32,
) -> HandleUseRequest {
HandleUseRequest::new(sensitive_authority(authority, classification), now, uses)
handle_use_for_audience(authority, classification, AUDIENCE, now, uses)
}

fn handle_use_for_audience(
authority: AuthorityCase<'_>,
classification: DataClassification,
audience: &str,
now: u64,
uses: u32,
) -> HandleUseRequest {
HandleUseRequest::new(
sensitive_authority(authority, classification),
audience,
now,
uses,
)
}

fn assert_disclosure_denied(authority: AuthorityCase<'_>, classification: DataClassification) {
Expand Down Expand Up @@ -216,7 +250,7 @@ fn every_supported_disclosure_outcome_is_preserved_by_exact_scope() {
}

#[test]
fn opaque_handle_use_is_bound_to_scope_classification_expiry_and_use_count() {
fn opaque_handle_use_is_bound_to_scope_classification_audience_expiry_and_use_count() {
let exact = exact_authority();
let scope = handle_scope(exact, DataClassification::PersonalData);
assert_eq!(
Expand All @@ -231,6 +265,19 @@ fn opaque_handle_use_is_bound_to_scope_classification_expiry_and_use_count() {
DataClassification::PersonalData,
);
assert_handle_scope_mismatch(exact, DataClassification::SensitivePersonalData);
assert_eq!(
evaluate_handle_use(
&handle_use_for_audience(
exact,
DataClassification::PersonalData,
"other_service",
1_999,
1,
),
&scope,
),
HandleUseDecision::AudienceMismatch
);
assert_eq!(
evaluate_handle_use(
&handle_use(exact, DataClassification::PersonalData, 2_000, 1),
Expand All @@ -247,6 +294,28 @@ fn opaque_handle_use_is_bound_to_scope_classification_expiry_and_use_count() {
);
}

#[test]
fn invalid_handle_audience_fails_closed_on_request_and_scope() {
let exact = exact_authority();
let valid_scope = handle_scope(exact, DataClassification::PersonalData);
let invalid_request =
handle_use_for_audience(exact, DataClassification::PersonalData, "", 1_999, 0);
assert_eq!(
evaluate_handle_use(&invalid_request, &valid_scope),
HandleUseDecision::AudienceMismatch
);

let invalid_scope =
handle_scope_for_audience(exact, DataClassification::PersonalData, "browser adapter");
assert_eq!(
evaluate_handle_use(
&handle_use(exact, DataClassification::PersonalData, 1_999, 0),
&invalid_scope,
),
HandleUseDecision::AudienceMismatch
);
}

#[test]
fn handle_scope_mismatch_covers_every_authority_dimension() {
assert_handle_scope_mismatch(
Expand Down
Loading
Loading