Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -5,6 +5,7 @@ All notable product and architecture changes are recorded here. Releases use imm
## Unreleased

### Added
- A current anonymous credential mints only its exact anonymous-session context, expires that context at the earlier of credential expiry or revocation, and tells the caller to present a current exact digest when authorization fails.
- Scoring-job cancel and lease-expiry fallback classification lock the current row until the caller transaction ends, so concurrent workers cannot rewrite terminal or unleased evidence.
- PostgreSQL operational-store readiness probe classifies the supported major version and write-readiness, and fails closed when a caller-declared required relation is missing.
- PostgreSQL scoring-job cancellation: queued, leased, or retry-scheduled work becomes cancelled without transferring a fence, exact replay is idempotent, and completed or quarantined evidence cannot be rewritten.
Expand Down
59 changes: 59 additions & 0 deletions src/anonymous_credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
//! identity, and append-only revocation evidence explicit so a later HTTP adapter cannot silently
//! widen anonymous-session authority.

use crate::anonymous_session::AnonymousSessionContext;
use crate::reference::normalized_reference;
use std::error::Error;
use std::fmt::{Display, Formatter};
Expand All @@ -30,6 +31,8 @@ pub enum AnonymousCredentialError {
InvalidLifetime,
/// A revocation replay tried to replace already-recorded immutable revocation evidence.
ConflictingRevocation,
/// The presented digest, binding, or server time did not authorize this credential.
Unauthorized,
}

impl Display for AnonymousCredentialError {
Expand All @@ -50,6 +53,9 @@ impl Display for AnonymousCredentialError {
Self::ConflictingRevocation => {
"anonymous credential revocation evidence cannot be replaced"
}
Self::Unauthorized => {
"present a current exact digest for this tenant, participant, and session"
}
})
}
}
Expand Down Expand Up @@ -240,6 +246,59 @@ impl AnonymousCredential {
}
}
}

/// Mint the exact anonymous-session context this credential currently authorizes.
///
/// A transport should call this after it hashes the presented bearer proof. The returned
/// context names this credential as authorization evidence and expires at the earlier of
/// credential expiry or recorded revocation. Later resource checks can then use that context
/// without seeing the raw proof.
///
/// # Errors
///
/// Returns [`AnonymousCredentialError::Unauthorized`] when the presented digest, tenant,
/// participant, session, or server time does not currently authorize this credential.
///
/// # Panics
///
/// Panics only if an already-authorized credential somehow lacks a valid session-context
/// binding. [`AnonymousCredential::new`] rejects those inputs, so a panic means an internal
/// invariant was broken rather than a caller mistake.
pub fn session_context(
&self,
presented_proof_digest: &str,
tenant_ref: &str,
participant_ref: &str,
session_ref: &str,
now_unix_ms: u64,
) -> Result<AnonymousSessionContext, AnonymousCredentialError> {
if !self.authorizes(
presented_proof_digest,
tenant_ref,
participant_ref,
session_ref,
now_unix_ms,
) {
return Err(AnonymousCredentialError::Unauthorized);
}
Ok(AnonymousSessionContext::new(
self.tenant_ref(),
self.participant_ref(),
self.session_ref(),
self.credential_ref(),
self.authority_expires_at_unix_ms(),
)
.expect("an authorized credential already carries valid session-context inputs"))
}

const fn authority_expires_at_unix_ms(&self) -> u64 {
match self.revoked_at_unix_ms {
Some(revoked_at_unix_ms) if revoked_at_unix_ms < self.expires_at_unix_ms => {
revoked_at_unix_ms
}
_ => self.expires_at_unix_ms,
}
}
}

fn required_reference(reference: &str) -> Result<&str, AnonymousCredentialError> {
Expand Down
1 change: 1 addition & 0 deletions tests/anonymous_credential_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ fn errors_are_stable_beginner_readable_and_have_no_hidden_source() {
AnonymousCredentialError::InvalidTimestamp,
AnonymousCredentialError::InvalidLifetime,
AnonymousCredentialError::ConflictingRevocation,
AnonymousCredentialError::Unauthorized,
];

for error in cases {
Expand Down
152 changes: 152 additions & 0 deletions tests/anonymous_credential_session_context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//! A current anonymous credential must mint only its exact session context.

use psychometrics_commons_runtime::anonymous_credential::{
AnonymousCredential, AnonymousCredentialError,
};
use std::error::Error;

const DIGEST_A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const DIGEST_B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";

fn credential() -> AnonymousCredential {
AnonymousCredential::new(
"anonymous_credential_alpha",
"tenant_alpha",
"participant_alpha",
"session_alpha",
DIGEST_A,
1_000,
2_000,
)
.unwrap()
}

#[test]
fn current_exact_digest_mints_the_bound_anonymous_session_context() {
let credential = credential();

let context = credential
.session_context(
DIGEST_A,
"tenant_alpha",
"participant_alpha",
"session_alpha",
1_500,
)
.unwrap();

assert_eq!(context.tenant_ref(), "tenant_alpha");
assert_eq!(context.participant_ref(), "participant_alpha");
assert_eq!(context.session_ref(), "session_alpha");
assert_eq!(
context.authorization_evidence_ref(),
"anonymous_credential_alpha"
);
assert_eq!(context.valid_until_unix_ms(), 2_000);
assert!(context.is_valid_for_binding_at(
"tenant_alpha",
"participant_alpha",
"session_alpha",
1_500
));
}

#[test]
fn expired_wrong_or_revoked_proofs_cannot_mint_session_authority() {
let mut credential = credential();

assert_eq!(
credential
.session_context(
DIGEST_A,
"tenant_alpha",
"participant_alpha",
"session_alpha",
2_000,
)
.unwrap_err(),
AnonymousCredentialError::Unauthorized
);
assert_eq!(
credential
.session_context(
DIGEST_B,
"tenant_alpha",
"participant_alpha",
"session_alpha",
1_500,
)
.unwrap_err(),
AnonymousCredentialError::Unauthorized
);
assert_eq!(
credential
.session_context(
DIGEST_A,
"tenant_other",
"participant_alpha",
"session_alpha",
1_500,
)
.unwrap_err(),
AnonymousCredentialError::Unauthorized
);

credential.revoke(1_400).unwrap();
assert_eq!(
credential
.session_context(
DIGEST_A,
"tenant_alpha",
"participant_alpha",
"session_alpha",
1_400,
)
.unwrap_err(),
AnonymousCredentialError::Unauthorized
);

let revoked_context = credential
.session_context(
DIGEST_A,
"tenant_alpha",
"participant_alpha",
"session_alpha",
1_399,
)
.unwrap();
assert_eq!(revoked_context.valid_until_unix_ms(), 1_400);
assert!(!revoked_context.is_valid_at(1_400));

let mut expired_then_revoked = AnonymousCredential::new(
"anonymous_credential_alpha",
"tenant_alpha",
"participant_alpha",
"session_alpha",
DIGEST_A,
1_000,
2_000,
)
.unwrap();
expired_then_revoked.revoke(2_000).unwrap();
let context = expired_then_revoked
.session_context(
DIGEST_A,
"tenant_alpha",
"participant_alpha",
"session_alpha",
1_999,
)
.unwrap();
assert_eq!(context.valid_until_unix_ms(), 2_000);
}

#[test]
fn unauthorized_error_tells_the_caller_to_present_current_exact_proof() {
let error = AnonymousCredentialError::Unauthorized;
assert_eq!(
error.to_string(),
"present a current exact digest for this tenant, participant, and session"
);
assert!(error.source().is_none());
}
Loading