mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
Cumulative PR layer #4772. Integrates canonical lifecycle admission, replay and projection schema repairs, runtime-generated authorization fixtures, desired-schema regeneration, canonical empty-upload support, and exact lifecycle audit regressions. Neighbor disclosure: protected route effects remain in #4783; invalidation and restore remain in #4789; relay production wiring remains in #4847. Signed-off-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
2215 lines
80 KiB
Rust
2215 lines
80 KiB
Rust
//! Origin-sealed deployment-operator authority for identity lifecycle work.
|
|
//!
|
|
//! The verifier is the only constructor for the opaque grants in this module.
|
|
//! It requires a configured operator to sign the exact NIP-98 request and,
|
|
//! when the closed deployment policy requires it, a distinct configured
|
|
//! approver. Every accepted proof is payload- and intent-bound before the
|
|
//! verifier returns authority to a caller.
|
|
|
|
use std::{fmt, future::Future, pin::Pin};
|
|
|
|
use buzz_core::CommunityId;
|
|
use chrono::{DateTime, Utc};
|
|
use nostr::{Event, EventId, PublicKey, TagKind};
|
|
use serde::Deserialize;
|
|
use sha2::{Digest, Sha256};
|
|
use thiserror::Error;
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
foundation::FederatedPrincipal, verify_nip98_event, AuthError, DEFAULT_REPLAY_TTL_SECS,
|
|
};
|
|
|
|
const MAX_CONFIGURED_OPERATORS: usize = 64;
|
|
const NIP98_EXCLUSIVE_LIFETIME_SECONDS: u64 = 60;
|
|
|
|
/// Atomic all-or-none replay fence for a complete one/two/three-proof set.
|
|
///
|
|
/// This is intentionally separate from the single-event `Nip98ReplayGuard`:
|
|
/// sequential claims can burn a valid subset and cannot prove one concurrent
|
|
/// winner for a multi-signature operator action.
|
|
pub trait OperatorProofSetReplayGuard: Send + Sync {
|
|
/// Consume one unit from a server-derived domain/action/actor quota.
|
|
///
|
|
/// The verifier calls this only after every proof is complete and
|
|
/// allowlisted, and before it consumes any replay identity.
|
|
fn try_admit_quota<'a>(
|
|
&'a self,
|
|
quota_scope: &'a str,
|
|
signer_attribution: &'a [u8; 32],
|
|
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>>;
|
|
|
|
/// Claim every distinct event id in one scope, or claim none of them.
|
|
fn try_mark_all_in_scope<'a>(
|
|
&'a self,
|
|
scope: &'a str,
|
|
event_ids: &'a [EventId],
|
|
ttl_secs: u64,
|
|
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>>;
|
|
}
|
|
|
|
/// Closed operator actions backed by the canonical identity lifecycle engine.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
#[repr(i16)]
|
|
pub enum OperatorLifecycleAction {
|
|
/// Retire one active identity/key pair without revoking the key globally.
|
|
Retire = 3,
|
|
/// Disable one principal, retiring its active binding when present.
|
|
Disable = 4,
|
|
/// Permanently revoke an event-author key.
|
|
Revoke = 5,
|
|
/// Replace one active binding with a freshly proven key.
|
|
Rotate = 6,
|
|
/// Consume one pending replacement fact and install its successor.
|
|
Recover = 7,
|
|
/// Consume one disabled-principal fact and install a successor.
|
|
Enable = 8,
|
|
}
|
|
|
|
impl OperatorLifecycleAction {
|
|
/// Parse the exact action segment exposed by the deployment operator API.
|
|
pub fn from_path_segment(value: &str) -> Option<Self> {
|
|
match value {
|
|
"revoke" => Some(Self::Revoke),
|
|
"rotate" => Some(Self::Rotate),
|
|
"recover" => Some(Self::Recover),
|
|
"enable" => Some(Self::Enable),
|
|
"retire" => Some(Self::Retire),
|
|
"disable" => Some(Self::Disable),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Exact action segment covered by the signed NIP-98 URL.
|
|
pub const fn as_path_segment(self) -> &'static str {
|
|
match self {
|
|
Self::Retire => "retire",
|
|
Self::Disable => "disable",
|
|
Self::Revoke => "revoke",
|
|
Self::Rotate => "rotate",
|
|
Self::Recover => "recover",
|
|
Self::Enable => "enable",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Opaque authority for one exact canonical lifecycle request.
|
|
///
|
|
/// This value has no public constructor and is not serializable. A valid
|
|
/// value proves that the closed configured approval policy was satisfied for
|
|
/// the exact signed payload and semantic intent.
|
|
///
|
|
/// ```compile_fail
|
|
/// use buzz_auth::VerifiedOperatorLifecycleGrant;
|
|
/// let _forged = VerifiedOperatorLifecycleGrant {};
|
|
/// ```
|
|
#[derive(Clone, PartialEq, Eq)]
|
|
pub struct VerifiedOperatorLifecycleGrant {
|
|
authorization_domain: CommunityId,
|
|
action: OperatorLifecycleAction,
|
|
operation_id: Uuid,
|
|
intent_digest: [u8; 32],
|
|
expected_revision: u64,
|
|
approval_policy_digest: [u8; 32],
|
|
signer_attribution: [u8; 32],
|
|
approval_attribution: Option<[u8; 32]>,
|
|
replacement_event_author_pubkey: Option<[u8; 32]>,
|
|
replacement_expires_at: Option<DateTime<Utc>>,
|
|
replacement_proof_attribution: Option<[u8; 32]>,
|
|
request_attribution: [u8; 32],
|
|
issued_at: DateTime<Utc>,
|
|
expires_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl VerifiedOperatorLifecycleGrant {
|
|
/// Server-resolved authorization domain bound by both operator proofs.
|
|
pub const fn authorization_domain(&self) -> CommunityId {
|
|
self.authorization_domain
|
|
}
|
|
|
|
/// Exact closed lifecycle action.
|
|
pub const fn action(&self) -> OperatorLifecycleAction {
|
|
self.action
|
|
}
|
|
|
|
/// Stable operation identity bound by the signed payload.
|
|
pub const fn operation_id(&self) -> Uuid {
|
|
self.operation_id
|
|
}
|
|
|
|
/// Canonical typed-intent digest bound by both signatures.
|
|
pub const fn intent_digest(&self) -> [u8; 32] {
|
|
self.intent_digest
|
|
}
|
|
|
|
/// Exact expected lifecycle revision or selector generation.
|
|
pub const fn expected_revision(&self) -> u64 {
|
|
self.expected_revision
|
|
}
|
|
|
|
/// Digest of the immutable verifier configuration and approval policy.
|
|
pub const fn approval_policy_digest(&self) -> [u8; 32] {
|
|
self.approval_policy_digest
|
|
}
|
|
|
|
/// Pseudonymous configured signer attribution.
|
|
pub const fn signer_attribution(&self) -> [u8; 32] {
|
|
self.signer_attribution
|
|
}
|
|
|
|
/// Independently configured approver attribution.
|
|
pub const fn approval_attribution(&self) -> Option<[u8; 32]> {
|
|
self.approval_attribution
|
|
}
|
|
|
|
/// Fresh replacement key proven by the exact request, when required by
|
|
/// Rotate, Recover, or Enable.
|
|
pub const fn replacement_event_author_pubkey(&self) -> Option<[u8; 32]> {
|
|
self.replacement_event_author_pubkey
|
|
}
|
|
|
|
/// Stable exclusive expiry requested in the signed replacement body.
|
|
pub const fn replacement_expires_at(&self) -> Option<DateTime<Utc>> {
|
|
self.replacement_expires_at
|
|
}
|
|
|
|
/// Stable attribution for the independently signed replacement proof.
|
|
pub const fn replacement_proof_attribution(&self) -> Option<[u8; 32]> {
|
|
self.replacement_proof_attribution
|
|
}
|
|
|
|
/// Stable attribution bound to semantic intent, excluding ephemeral proof
|
|
/// IDs so a newly signed exact retry can reach canonical DB replay.
|
|
pub const fn request_attribution(&self) -> [u8; 32] {
|
|
self.request_attribution
|
|
}
|
|
|
|
/// Latest issuance time across the two exact NIP-98 proofs.
|
|
pub const fn issued_at(&self) -> DateTime<Utc> {
|
|
self.issued_at
|
|
}
|
|
|
|
/// Exclusive trusted expiry. For replacement actions this is the minimum
|
|
/// of the primary proof, configured approver proof, replacement proof,
|
|
/// and requested successor bound. Callers must recheck it immediately
|
|
/// before the canonical mutation and additionally apply the authoritative
|
|
/// database policy bound when persisting the successor.
|
|
pub const fn expires_at(&self) -> DateTime<Utc> {
|
|
self.expires_at
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for VerifiedOperatorLifecycleGrant {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str("VerifiedOperatorLifecycleGrant([REDACTED])")
|
|
}
|
|
}
|
|
|
|
/// Opaque operator-approved community-provision binding intent.
|
|
///
|
|
/// Provisioning remains a separate workflow from lifecycle mutation. This
|
|
/// value lets that workflow consume the same closed operator policy without
|
|
/// accepting a caller-selected `approved` flag or raw attribution.
|
|
///
|
|
/// ```compile_fail
|
|
/// use buzz_auth::VerifiedProvisionBindingIntent;
|
|
/// let _forged = VerifiedProvisionBindingIntent {};
|
|
/// ```
|
|
#[derive(Clone, PartialEq, Eq)]
|
|
pub struct VerifiedProvisionBindingIntent {
|
|
authorization_domain: CommunityId,
|
|
operation_id: Uuid,
|
|
policy_revision: u64,
|
|
policy_digest: [u8; 32],
|
|
principal: FederatedPrincipal,
|
|
selected_event_author_pubkey: [u8; 32],
|
|
intent_digest: [u8; 32],
|
|
approval_policy_digest: [u8; 32],
|
|
signer_attribution: [u8; 32],
|
|
approval_attribution: Option<[u8; 32]>,
|
|
request_attribution: [u8; 32],
|
|
issued_at: DateTime<Utc>,
|
|
expires_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl VerifiedProvisionBindingIntent {
|
|
/// Server-resolved domain explicitly sealed by the signed payload.
|
|
pub const fn authorization_domain(&self) -> CommunityId {
|
|
self.authorization_domain
|
|
}
|
|
|
|
/// Exact operation identity explicitly sealed by the signed payload.
|
|
pub const fn operation_id(&self) -> Uuid {
|
|
self.operation_id
|
|
}
|
|
|
|
/// Exact local enrollment-policy revision selected by the operator.
|
|
pub const fn policy_revision(&self) -> u64 {
|
|
self.policy_revision
|
|
}
|
|
|
|
/// Exact local enrollment-policy digest selected by the operator.
|
|
pub const fn policy_digest(&self) -> [u8; 32] {
|
|
self.policy_digest
|
|
}
|
|
|
|
/// Borrow the exact principal coordinates selected by the operator.
|
|
pub fn principal_storage_key(&self) -> crate::FederatedPrincipalStorageKey<'_> {
|
|
self.principal.storage_key()
|
|
}
|
|
|
|
/// Exact event-author key selected by the provision intent.
|
|
pub const fn selected_event_author_pubkey(&self) -> [u8; 32] {
|
|
self.selected_event_author_pubkey
|
|
}
|
|
|
|
/// Digest of the exact payload-bound provisioning intent.
|
|
pub const fn intent_digest(&self) -> [u8; 32] {
|
|
self.intent_digest
|
|
}
|
|
|
|
/// Stable two-party attribution for the provisioning workflow.
|
|
pub const fn request_attribution(&self) -> [u8; 32] {
|
|
self.request_attribution
|
|
}
|
|
|
|
/// Latest issuance time across the two exact NIP-98 proofs.
|
|
pub const fn issued_at(&self) -> DateTime<Utc> {
|
|
self.issued_at
|
|
}
|
|
|
|
/// Exclusive trusted expiry.
|
|
pub const fn expires_at(&self) -> DateTime<Utc> {
|
|
self.expires_at
|
|
}
|
|
|
|
/// Pseudonymous configured signer attribution.
|
|
pub const fn signer_attribution(&self) -> [u8; 32] {
|
|
self.signer_attribution
|
|
}
|
|
|
|
/// Independently configured approver attribution.
|
|
pub const fn approval_attribution(&self) -> Option<[u8; 32]> {
|
|
self.approval_attribution
|
|
}
|
|
|
|
/// Digest of the immutable verifier configuration and approval policy.
|
|
pub const fn approval_policy_digest(&self) -> [u8; 32] {
|
|
self.approval_policy_digest
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for VerifiedProvisionBindingIntent {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str("VerifiedProvisionBindingIntent([REDACTED])")
|
|
}
|
|
}
|
|
|
|
/// Configuration or verification failures for the sealed operator boundary.
|
|
#[derive(Debug, Error, PartialEq, Eq)]
|
|
pub enum OperatorLifecycleGrantError {
|
|
/// The configured operator set cannot satisfy the closed approval policy.
|
|
#[error("operator lifecycle verifier configuration is invalid")]
|
|
InvalidConfiguration,
|
|
/// The typed lifecycle/provisioning coordinates are invalid.
|
|
#[error("operator lifecycle request is invalid")]
|
|
InvalidRequest,
|
|
/// One of the exact NIP-98 proofs was malformed, stale, or mismatched.
|
|
#[error("operator lifecycle credential is invalid")]
|
|
InvalidCredential,
|
|
/// A signer is absent from the configured deployment operator set.
|
|
#[error("operator lifecycle signer is not configured")]
|
|
Unauthenticated,
|
|
/// The two required proofs were produced by the same signer.
|
|
#[error("operator lifecycle approval is not independent")]
|
|
ApprovalNotIndependent,
|
|
/// One of the exact proof identities was already consumed.
|
|
#[error("operator lifecycle credential was replayed")]
|
|
Replayed,
|
|
/// The server-owned authorization-domain quota rejected the action.
|
|
#[error("operator lifecycle quota is exhausted")]
|
|
QuotaExceeded,
|
|
/// Shared quota state was unavailable; verification failed closed.
|
|
#[error("operator lifecycle quota is unavailable")]
|
|
QuotaUnavailable,
|
|
/// The shared replay fence was unavailable; verification failed closed.
|
|
#[error("operator lifecycle replay fence is unavailable")]
|
|
ReplayUnavailable,
|
|
}
|
|
|
|
/// Exact NIP-98/configuration verifier and sole mint for operator authority.
|
|
#[derive(Clone)]
|
|
pub struct OperatorLifecycleVerifier {
|
|
configured_operators: Box<[PublicKey]>,
|
|
approval_policy: OperatorApprovalPolicy,
|
|
approval_policy_digest: [u8; 32],
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum OperatorApprovalPolicy {
|
|
ConfiguredSigner = 1,
|
|
IndependentConfiguredApprover = 2,
|
|
}
|
|
|
|
impl OperatorLifecycleVerifier {
|
|
/// Install the stock policy: one exact payload-bound configured signer.
|
|
pub fn new(configured_operators: Vec<PublicKey>) -> Result<Self, OperatorLifecycleGrantError> {
|
|
Self::with_policy(
|
|
configured_operators,
|
|
OperatorApprovalPolicy::ConfiguredSigner,
|
|
)
|
|
}
|
|
|
|
/// Install a deployment policy requiring one independently configured
|
|
/// approver in addition to the exact payload-bound signer.
|
|
pub fn requiring_independent_approval(
|
|
configured_operators: Vec<PublicKey>,
|
|
) -> Result<Self, OperatorLifecycleGrantError> {
|
|
Self::with_policy(
|
|
configured_operators,
|
|
OperatorApprovalPolicy::IndependentConfiguredApprover,
|
|
)
|
|
}
|
|
|
|
fn with_policy(
|
|
mut configured_operators: Vec<PublicKey>,
|
|
approval_policy: OperatorApprovalPolicy,
|
|
) -> Result<Self, OperatorLifecycleGrantError> {
|
|
configured_operators.sort_unstable_by_key(|key| key.to_bytes());
|
|
configured_operators.dedup();
|
|
let minimum = match approval_policy {
|
|
OperatorApprovalPolicy::ConfiguredSigner => 1,
|
|
OperatorApprovalPolicy::IndependentConfiguredApprover => 2,
|
|
};
|
|
if configured_operators.len() < minimum
|
|
|| configured_operators.len() > MAX_CONFIGURED_OPERATORS
|
|
{
|
|
return Err(OperatorLifecycleGrantError::InvalidConfiguration);
|
|
}
|
|
let approval_policy_digest = operator_policy_digest(approval_policy, &configured_operators);
|
|
Ok(Self {
|
|
configured_operators: configured_operators.into_boxed_slice(),
|
|
approval_policy,
|
|
approval_policy_digest,
|
|
})
|
|
}
|
|
|
|
/// Whether this closed deployment policy requires an independent approval.
|
|
pub const fn requires_independent_approval(&self) -> bool {
|
|
matches!(
|
|
self.approval_policy,
|
|
OperatorApprovalPolicy::IndependentConfiguredApprover
|
|
)
|
|
}
|
|
|
|
/// Digest of the sorted allowlist and closed approval policy.
|
|
pub const fn approval_policy_digest(&self) -> [u8; 32] {
|
|
self.approval_policy_digest
|
|
}
|
|
|
|
/// Verify exact NIP-98 authority for one signed lifecycle body.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn verify_lifecycle(
|
|
&self,
|
|
authorization_event_json: &str,
|
|
independent_approval_event_json: Option<&str>,
|
|
replacement_proof_event_json: Option<&str>,
|
|
expected_url: &str,
|
|
request_body: &[u8],
|
|
replay_guard: &dyn OperatorProofSetReplayGuard,
|
|
authorization_domain: CommunityId,
|
|
action: OperatorLifecycleAction,
|
|
operation_id: Uuid,
|
|
intent_digest: [u8; 32],
|
|
expected_revision: u64,
|
|
replacement_event_author_pubkey: Option<[u8; 32]>,
|
|
replacement_expires_at: Option<DateTime<Utc>>,
|
|
) -> Result<VerifiedOperatorLifecycleGrant, OperatorLifecycleGrantError> {
|
|
if authorization_domain.as_uuid().is_nil()
|
|
|| operation_id.is_nil()
|
|
|| intent_digest == [0; 32]
|
|
|| expected_revision == 0
|
|
{
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest);
|
|
}
|
|
validate_lifecycle_url(expected_url, authorization_domain, action)?;
|
|
let signed = parse_lifecycle_coordinates(request_body, action)?;
|
|
if signed.operation_id != operation_id
|
|
|| signed.expected_revision != expected_revision
|
|
|| signed.replacement.map(|value| value.event_author_pubkey)
|
|
!= replacement_event_author_pubkey
|
|
|| signed.replacement.map(|value| value.requested_expires_at) != replacement_expires_at
|
|
{
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest);
|
|
}
|
|
let replay_scope = format!(
|
|
"operator-lifecycle:{}:{}:{}",
|
|
authorization_domain.as_uuid(),
|
|
action.as_path_segment(),
|
|
operation_id
|
|
);
|
|
let proof = self
|
|
.verify_operator_proofs(
|
|
authorization_event_json,
|
|
independent_approval_event_json,
|
|
expected_url,
|
|
request_body,
|
|
Some(intent_digest),
|
|
)
|
|
.await?;
|
|
let replacement = self
|
|
.verify_replacement_proof(
|
|
action,
|
|
replacement_proof_event_json,
|
|
signed.replacement,
|
|
expected_url,
|
|
request_body,
|
|
authorization_domain,
|
|
operation_id,
|
|
intent_digest,
|
|
)
|
|
.await?;
|
|
let expires_at = replacement
|
|
.as_ref()
|
|
.map(|replacement| {
|
|
proof
|
|
.expires_at
|
|
.min(replacement.credential_expires_at)
|
|
.min(replacement.requested_expires_at)
|
|
})
|
|
.unwrap_or(proof.expires_at);
|
|
let issued_at = replacement
|
|
.as_ref()
|
|
.map(|replacement| proof.issued_at.max(replacement.issued_at))
|
|
.unwrap_or(proof.issued_at);
|
|
let verification_now = Utc::now();
|
|
if issued_at > verification_now || verification_now >= expires_at {
|
|
return Err(OperatorLifecycleGrantError::InvalidCredential);
|
|
}
|
|
let mut replay_event_ids = Vec::with_capacity(3);
|
|
replay_event_ids.push(proof.signer_event_id);
|
|
replay_event_ids.extend(proof.approval_event_id);
|
|
replay_event_ids.extend(replacement.as_ref().map(|value| value.event_id));
|
|
let quota_scope = format!(
|
|
"operator-quota:{}:{}",
|
|
authorization_domain.as_uuid(),
|
|
action.as_path_segment()
|
|
);
|
|
claim_server_quota(replay_guard, "a_scope, &proof.signer_attribution).await?;
|
|
claim_replay_set(replay_guard, &replay_scope, &replay_event_ids).await?;
|
|
let request_attribution = framed_digest(
|
|
b"buzz:verified-operator-lifecycle-grant:v1",
|
|
&[
|
|
authorization_domain.as_uuid().as_bytes(),
|
|
&(action as i16).to_be_bytes(),
|
|
operation_id.as_bytes(),
|
|
&intent_digest,
|
|
&expected_revision.to_be_bytes(),
|
|
&self.approval_policy_digest,
|
|
&proof.signer_attribution,
|
|
&proof.approval_attribution.unwrap_or([0; 32]),
|
|
&replacement
|
|
.as_ref()
|
|
.map(|value| value.event_author_pubkey)
|
|
.unwrap_or([0; 32]),
|
|
&replacement
|
|
.as_ref()
|
|
.map(|value| value.requested_expires_at.timestamp().to_be_bytes())
|
|
.unwrap_or([0; 8]),
|
|
],
|
|
);
|
|
Ok(VerifiedOperatorLifecycleGrant {
|
|
authorization_domain,
|
|
action,
|
|
operation_id,
|
|
intent_digest,
|
|
expected_revision,
|
|
approval_policy_digest: self.approval_policy_digest,
|
|
signer_attribution: proof.signer_attribution,
|
|
approval_attribution: proof.approval_attribution,
|
|
replacement_event_author_pubkey: replacement
|
|
.as_ref()
|
|
.map(|value| value.event_author_pubkey),
|
|
replacement_expires_at: replacement.as_ref().map(|value| value.requested_expires_at),
|
|
replacement_proof_attribution: replacement
|
|
.as_ref()
|
|
.map(|value| value.proof_attribution),
|
|
request_attribution,
|
|
issued_at,
|
|
expires_at,
|
|
})
|
|
}
|
|
|
|
/// Verify exact NIP-98 authority for one explicit binding provision body.
|
|
pub async fn verify_provision_binding(
|
|
&self,
|
|
authorization_event_json: &str,
|
|
independent_approval_event_json: Option<&str>,
|
|
expected_url: &str,
|
|
request_body: &[u8],
|
|
replay_guard: &dyn OperatorProofSetReplayGuard,
|
|
authorization_domain: CommunityId,
|
|
) -> Result<VerifiedProvisionBindingIntent, OperatorLifecycleGrantError> {
|
|
if authorization_domain.as_uuid().is_nil() {
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest);
|
|
}
|
|
validate_provision_url(expected_url)?;
|
|
let signed = parse_provision_coordinates(request_body, authorization_domain)?;
|
|
let body_digest: [u8; 32] = Sha256::digest(request_body).into();
|
|
let intent_digest = framed_digest(
|
|
b"buzz:operator-provision-binding-intent:v2",
|
|
&[
|
|
authorization_domain.as_uuid().as_bytes(),
|
|
signed.operation_id.as_bytes(),
|
|
&signed.policy_revision.to_be_bytes(),
|
|
&signed.policy_digest,
|
|
signed.principal.storage_key().issuer().as_bytes(),
|
|
signed.principal.storage_key().subject().as_bytes(),
|
|
&signed.selected_event_author_pubkey,
|
|
&body_digest,
|
|
],
|
|
);
|
|
let replay_scope = format!(
|
|
"operator-provision:{}:{}",
|
|
authorization_domain.as_uuid(),
|
|
signed.operation_id
|
|
);
|
|
let proof = self
|
|
.verify_operator_proofs(
|
|
authorization_event_json,
|
|
independent_approval_event_json,
|
|
expected_url,
|
|
request_body,
|
|
None,
|
|
)
|
|
.await?;
|
|
let request_attribution = framed_digest(
|
|
b"buzz:verified-operator-provision-intent:v2",
|
|
&[
|
|
&intent_digest,
|
|
&self.approval_policy_digest,
|
|
&proof.signer_attribution,
|
|
&proof.approval_attribution.unwrap_or([0; 32]),
|
|
],
|
|
);
|
|
let mut replay_event_ids = Vec::with_capacity(2);
|
|
replay_event_ids.push(proof.signer_event_id);
|
|
replay_event_ids.extend(proof.approval_event_id);
|
|
let quota_scope = format!(
|
|
"operator-quota:{}:provision",
|
|
authorization_domain.as_uuid()
|
|
);
|
|
claim_server_quota(replay_guard, "a_scope, &proof.signer_attribution).await?;
|
|
claim_replay_set(replay_guard, &replay_scope, &replay_event_ids).await?;
|
|
Ok(VerifiedProvisionBindingIntent {
|
|
authorization_domain,
|
|
operation_id: signed.operation_id,
|
|
policy_revision: signed.policy_revision,
|
|
policy_digest: signed.policy_digest,
|
|
principal: signed.principal,
|
|
selected_event_author_pubkey: signed.selected_event_author_pubkey,
|
|
intent_digest,
|
|
approval_policy_digest: self.approval_policy_digest,
|
|
signer_attribution: proof.signer_attribution,
|
|
approval_attribution: proof.approval_attribution,
|
|
request_attribution,
|
|
issued_at: proof.issued_at,
|
|
expires_at: proof.expires_at,
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn verify_operator_proofs(
|
|
&self,
|
|
authorization_event_json: &str,
|
|
independent_approval_event_json: Option<&str>,
|
|
expected_url: &str,
|
|
request_body: &[u8],
|
|
intent_digest: Option<[u8; 32]>,
|
|
) -> Result<VerifiedOperatorPair, OperatorLifecycleGrantError> {
|
|
if expected_url.is_empty() || request_body.is_empty() {
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest);
|
|
}
|
|
let signer_event = strict_nip98_event(
|
|
authorization_event_json,
|
|
expected_url,
|
|
request_body,
|
|
intent_digest,
|
|
)?;
|
|
if !self.configured_operators.contains(&signer_event.pubkey) {
|
|
return Err(OperatorLifecycleGrantError::Unauthenticated);
|
|
}
|
|
let approval_event = match (self.approval_policy, independent_approval_event_json) {
|
|
(OperatorApprovalPolicy::ConfiguredSigner, None) => None,
|
|
(OperatorApprovalPolicy::ConfiguredSigner, Some(_))
|
|
| (OperatorApprovalPolicy::IndependentConfiguredApprover, None) => {
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest)
|
|
}
|
|
(OperatorApprovalPolicy::IndependentConfiguredApprover, Some(value)) => {
|
|
let event = strict_nip98_event(value, expected_url, request_body, intent_digest)?;
|
|
if event.pubkey == signer_event.pubkey {
|
|
return Err(OperatorLifecycleGrantError::ApprovalNotIndependent);
|
|
}
|
|
if !self.configured_operators.contains(&event.pubkey) {
|
|
return Err(OperatorLifecycleGrantError::Unauthenticated);
|
|
}
|
|
Some(event)
|
|
}
|
|
};
|
|
let signer_seconds = signer_event.created_at.as_secs();
|
|
let approval_seconds = approval_event
|
|
.as_ref()
|
|
.map(|event| event.created_at.as_secs())
|
|
.unwrap_or(signer_seconds);
|
|
let issued_seconds = signer_seconds.max(approval_seconds);
|
|
let expires_seconds = signer_seconds
|
|
.saturating_add(NIP98_EXCLUSIVE_LIFETIME_SECONDS)
|
|
.min(approval_seconds.saturating_add(NIP98_EXCLUSIVE_LIFETIME_SECONDS));
|
|
let issued_at = DateTime::<Utc>::from_timestamp(
|
|
i64::try_from(issued_seconds)
|
|
.map_err(|_| OperatorLifecycleGrantError::InvalidCredential)?,
|
|
0,
|
|
)
|
|
.ok_or(OperatorLifecycleGrantError::InvalidCredential)?;
|
|
let expires_at = DateTime::<Utc>::from_timestamp(
|
|
i64::try_from(expires_seconds)
|
|
.map_err(|_| OperatorLifecycleGrantError::InvalidCredential)?,
|
|
0,
|
|
)
|
|
.ok_or(OperatorLifecycleGrantError::InvalidCredential)?;
|
|
let verification_now = Utc::now();
|
|
if issued_at > verification_now || verification_now >= expires_at {
|
|
return Err(OperatorLifecycleGrantError::InvalidCredential);
|
|
}
|
|
Ok(VerifiedOperatorPair {
|
|
signer_event_id: signer_event.id,
|
|
approval_event_id: approval_event.as_ref().map(|event| event.id),
|
|
signer_attribution: operator_attribution(signer_event.pubkey),
|
|
approval_attribution: approval_event
|
|
.as_ref()
|
|
.map(|event| operator_attribution(event.pubkey)),
|
|
issued_at,
|
|
expires_at,
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn verify_replacement_proof(
|
|
&self,
|
|
action: OperatorLifecycleAction,
|
|
replacement_proof_event_json: Option<&str>,
|
|
replacement: Option<SignedReplacementCoordinates>,
|
|
expected_url: &str,
|
|
request_body: &[u8],
|
|
authorization_domain: CommunityId,
|
|
operation_id: Uuid,
|
|
intent_digest: [u8; 32],
|
|
) -> Result<Option<VerifiedReplacementPair>, OperatorLifecycleGrantError> {
|
|
let required = matches!(
|
|
action,
|
|
OperatorLifecycleAction::Rotate
|
|
| OperatorLifecycleAction::Recover
|
|
| OperatorLifecycleAction::Enable
|
|
);
|
|
let (event_json, replacement) = match (replacement_proof_event_json, replacement) {
|
|
(Some(event_json), Some(replacement)) if required => (event_json, replacement),
|
|
(None, None) if !required => return Ok(None),
|
|
_ => return Err(OperatorLifecycleGrantError::InvalidRequest),
|
|
};
|
|
let event =
|
|
strict_nip98_event(event_json, expected_url, request_body, Some(intent_digest))?;
|
|
if event.pubkey.to_bytes() != replacement.event_author_pubkey {
|
|
return Err(OperatorLifecycleGrantError::InvalidCredential);
|
|
}
|
|
let expires_seconds = event
|
|
.created_at
|
|
.as_secs()
|
|
.saturating_add(NIP98_EXCLUSIVE_LIFETIME_SECONDS);
|
|
let expires_at = DateTime::<Utc>::from_timestamp(
|
|
i64::try_from(expires_seconds)
|
|
.map_err(|_| OperatorLifecycleGrantError::InvalidCredential)?,
|
|
0,
|
|
)
|
|
.ok_or(OperatorLifecycleGrantError::InvalidCredential)?;
|
|
let issued_at = DateTime::<Utc>::from_timestamp(
|
|
i64::try_from(event.created_at.as_secs())
|
|
.map_err(|_| OperatorLifecycleGrantError::InvalidCredential)?,
|
|
0,
|
|
)
|
|
.ok_or(OperatorLifecycleGrantError::InvalidCredential)?;
|
|
let verification_now = Utc::now();
|
|
if issued_at > verification_now || verification_now >= expires_at {
|
|
return Err(OperatorLifecycleGrantError::InvalidCredential);
|
|
}
|
|
Ok(Some(VerifiedReplacementPair {
|
|
event_id: event.id,
|
|
event_author_pubkey: replacement.event_author_pubkey,
|
|
requested_expires_at: replacement.requested_expires_at,
|
|
proof_attribution: framed_digest(
|
|
b"buzz:operator-replacement-proof-attribution:v2",
|
|
&[
|
|
authorization_domain.as_uuid().as_bytes(),
|
|
operation_id.as_bytes(),
|
|
&(action as i16).to_be_bytes(),
|
|
&intent_digest,
|
|
&replacement.event_author_pubkey,
|
|
&replacement.requested_expires_at.timestamp().to_be_bytes(),
|
|
],
|
|
),
|
|
issued_at,
|
|
credential_expires_at: expires_at,
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for OperatorLifecycleVerifier {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str("OperatorLifecycleVerifier([REDACTED])")
|
|
}
|
|
}
|
|
|
|
struct VerifiedOperatorPair {
|
|
signer_event_id: EventId,
|
|
approval_event_id: Option<EventId>,
|
|
signer_attribution: [u8; 32],
|
|
approval_attribution: Option<[u8; 32]>,
|
|
issued_at: DateTime<Utc>,
|
|
expires_at: DateTime<Utc>,
|
|
}
|
|
|
|
struct VerifiedReplacementPair {
|
|
event_id: EventId,
|
|
event_author_pubkey: [u8; 32],
|
|
requested_expires_at: DateTime<Utc>,
|
|
proof_attribution: [u8; 32],
|
|
issued_at: DateTime<Utc>,
|
|
credential_expires_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct SignedReplacementCoordinates {
|
|
event_author_pubkey: [u8; 32],
|
|
requested_expires_at: DateTime<Utc>,
|
|
}
|
|
|
|
struct SignedLifecycleCoordinates {
|
|
operation_id: Uuid,
|
|
expected_revision: u64,
|
|
replacement: Option<SignedReplacementCoordinates>,
|
|
}
|
|
|
|
struct SignedProvisionCoordinates {
|
|
operation_id: Uuid,
|
|
policy_revision: u64,
|
|
policy_digest: [u8; 32],
|
|
principal: FederatedPrincipal,
|
|
selected_event_author_pubkey: [u8; 32],
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LifecycleOperationWireCoordinates {
|
|
operation_id: Uuid,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LifecycleWireCoordinates {
|
|
operation: LifecycleOperationWireCoordinates,
|
|
expected_revision: u64,
|
|
#[serde(default)]
|
|
replacement: Option<ReplacementWireCoordinates>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ReplacementWireCoordinates {
|
|
event_author_pubkey: String,
|
|
expires_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ProvisionWireCoordinates {
|
|
authorization_domain: Uuid,
|
|
operation_id: Uuid,
|
|
policy_revision: u64,
|
|
policy_digest: String,
|
|
issuer: String,
|
|
subject: String,
|
|
selected_event_author_pubkey: String,
|
|
}
|
|
|
|
fn parse_lifecycle_coordinates(
|
|
request_body: &[u8],
|
|
action: OperatorLifecycleAction,
|
|
) -> Result<SignedLifecycleCoordinates, OperatorLifecycleGrantError> {
|
|
let wire: LifecycleWireCoordinates = serde_json::from_slice(request_body)
|
|
.map_err(|_| OperatorLifecycleGrantError::InvalidRequest)?;
|
|
if wire.operation.operation_id.is_nil() || wire.expected_revision == 0 {
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest);
|
|
}
|
|
let requires_replacement = matches!(
|
|
action,
|
|
OperatorLifecycleAction::Rotate
|
|
| OperatorLifecycleAction::Recover
|
|
| OperatorLifecycleAction::Enable
|
|
);
|
|
let replacement = match wire.replacement {
|
|
Some(replacement) if requires_replacement => {
|
|
if replacement.expires_at <= Utc::now() {
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest);
|
|
}
|
|
Some(SignedReplacementCoordinates {
|
|
event_author_pubkey: parse_hex_32(&replacement.event_author_pubkey)?,
|
|
requested_expires_at: replacement.expires_at,
|
|
})
|
|
}
|
|
None if !requires_replacement => None,
|
|
_ => return Err(OperatorLifecycleGrantError::InvalidRequest),
|
|
};
|
|
Ok(SignedLifecycleCoordinates {
|
|
operation_id: wire.operation.operation_id,
|
|
expected_revision: wire.expected_revision,
|
|
replacement,
|
|
})
|
|
}
|
|
|
|
fn parse_provision_coordinates(
|
|
request_body: &[u8],
|
|
authorization_domain: CommunityId,
|
|
) -> Result<SignedProvisionCoordinates, OperatorLifecycleGrantError> {
|
|
let wire: ProvisionWireCoordinates = serde_json::from_slice(request_body)
|
|
.map_err(|_| OperatorLifecycleGrantError::InvalidRequest)?;
|
|
if wire.authorization_domain != *authorization_domain.as_uuid()
|
|
|| wire.operation_id.is_nil()
|
|
|| wire.policy_revision == 0
|
|
{
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest);
|
|
}
|
|
let principal = FederatedPrincipal::from_verified_parts(wire.issuer, wire.subject)
|
|
.ok_or(OperatorLifecycleGrantError::InvalidRequest)?;
|
|
Ok(SignedProvisionCoordinates {
|
|
operation_id: wire.operation_id,
|
|
policy_revision: wire.policy_revision,
|
|
policy_digest: parse_hex_32(&wire.policy_digest)?,
|
|
principal,
|
|
selected_event_author_pubkey: parse_hex_32(&wire.selected_event_author_pubkey)?,
|
|
})
|
|
}
|
|
|
|
fn validate_lifecycle_url(
|
|
expected_url: &str,
|
|
authorization_domain: CommunityId,
|
|
action: OperatorLifecycleAction,
|
|
) -> Result<(), OperatorLifecycleGrantError> {
|
|
let url =
|
|
url::Url::parse(expected_url).map_err(|_| OperatorLifecycleGrantError::InvalidRequest)?;
|
|
let expected_path = format!(
|
|
"/operator/communities/{}/identity-lifecycle/{}",
|
|
authorization_domain.as_uuid(),
|
|
action.as_path_segment()
|
|
);
|
|
if url.scheme() != "https"
|
|
|| !url.username().is_empty()
|
|
|| url.password().is_some()
|
|
|| url.host_str().is_none()
|
|
|| url.path() != expected_path
|
|
|| url.query().is_some()
|
|
|| url.fragment().is_some()
|
|
{
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest);
|
|
}
|
|
let canonical = format!("{}{}", url.origin().ascii_serialization(), expected_path);
|
|
if expected_url != canonical {
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_provision_url(expected_url: &str) -> Result<(), OperatorLifecycleGrantError> {
|
|
const CANONICAL_PATH: &str = "/operator/identity-bindings/provision";
|
|
|
|
let url =
|
|
url::Url::parse(expected_url).map_err(|_| OperatorLifecycleGrantError::InvalidRequest)?;
|
|
if url.scheme() != "https"
|
|
|| !url.username().is_empty()
|
|
|| url.password().is_some()
|
|
|| url.host_str().is_none()
|
|
|| url.path() != CANONICAL_PATH
|
|
|| url.query().is_some()
|
|
|| url.fragment().is_some()
|
|
{
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest);
|
|
}
|
|
let canonical = format!("{}{}", url.origin().ascii_serialization(), CANONICAL_PATH);
|
|
if expected_url != canonical {
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_hex_32(value: &str) -> Result<[u8; 32], OperatorLifecycleGrantError> {
|
|
let decoded = hex::decode(value).map_err(|_| OperatorLifecycleGrantError::InvalidRequest)?;
|
|
let exact: [u8; 32] = decoded
|
|
.try_into()
|
|
.map_err(|_| OperatorLifecycleGrantError::InvalidRequest)?;
|
|
if exact == [0; 32] {
|
|
return Err(OperatorLifecycleGrantError::InvalidRequest);
|
|
}
|
|
Ok(exact)
|
|
}
|
|
|
|
fn operator_policy_digest(policy: OperatorApprovalPolicy, keys: &[PublicKey]) -> [u8; 32] {
|
|
let mut digest = Sha256::new();
|
|
let domain = b"buzz:operator-approval-policy:v1";
|
|
digest.update((domain.len() as u64).to_be_bytes());
|
|
digest.update(domain);
|
|
digest.update(1_u64.to_be_bytes());
|
|
digest.update([policy as u8]);
|
|
for key in keys {
|
|
digest.update(32_u64.to_be_bytes());
|
|
digest.update(key.to_bytes());
|
|
}
|
|
digest.finalize().into()
|
|
}
|
|
|
|
fn strict_nip98_event(
|
|
event_json: &str,
|
|
expected_url: &str,
|
|
request_body: &[u8],
|
|
intent_digest: Option<[u8; 32]>,
|
|
) -> Result<Event, OperatorLifecycleGrantError> {
|
|
let event: Event = serde_json::from_str(event_json)
|
|
.map_err(|_| OperatorLifecycleGrantError::InvalidCredential)?;
|
|
let event_time = DateTime::<Utc>::from_timestamp(
|
|
i64::try_from(event.created_at.as_secs())
|
|
.map_err(|_| OperatorLifecycleGrantError::InvalidCredential)?,
|
|
0,
|
|
)
|
|
.ok_or(OperatorLifecycleGrantError::InvalidCredential)?;
|
|
if event_time > Utc::now() {
|
|
return Err(OperatorLifecycleGrantError::InvalidCredential);
|
|
}
|
|
if event
|
|
.tags
|
|
.iter()
|
|
.filter(|tag| tag.kind() == TagKind::Payload)
|
|
.count()
|
|
!= 1
|
|
{
|
|
return Err(OperatorLifecycleGrantError::InvalidCredential);
|
|
}
|
|
if let Some(intent_digest) = intent_digest {
|
|
let expected_intent = hex::encode(intent_digest);
|
|
let mut intent_tags = event.tags.iter().filter(|tag| {
|
|
tag.as_slice()
|
|
.first()
|
|
.is_some_and(|kind| kind == "buzz-intent")
|
|
});
|
|
let exact_intent = intent_tags
|
|
.next()
|
|
.and_then(|tag| tag.as_slice().get(1))
|
|
.is_some_and(|value| value == &expected_intent)
|
|
&& intent_tags.next().is_none();
|
|
if !exact_intent {
|
|
return Err(OperatorLifecycleGrantError::InvalidCredential);
|
|
}
|
|
}
|
|
verify_nip98_event(event_json, expected_url, "POST", Some(request_body))
|
|
.map_err(|_| OperatorLifecycleGrantError::InvalidCredential)?;
|
|
Ok(event)
|
|
}
|
|
|
|
async fn claim_replay_set(
|
|
replay_guard: &dyn OperatorProofSetReplayGuard,
|
|
scope: &str,
|
|
event_ids: &[EventId],
|
|
) -> Result<(), OperatorLifecycleGrantError> {
|
|
let valid_cardinality = (1..=3).contains(&event_ids.len());
|
|
let distinct = event_ids
|
|
.iter()
|
|
.enumerate()
|
|
.all(|(index, event_id)| !event_ids[..index].contains(event_id));
|
|
if !valid_cardinality || !distinct {
|
|
return Err(OperatorLifecycleGrantError::InvalidCredential);
|
|
}
|
|
match replay_guard
|
|
.try_mark_all_in_scope(scope, event_ids, DEFAULT_REPLAY_TTL_SECS)
|
|
.await
|
|
{
|
|
Ok(true) => Ok(()),
|
|
Ok(false) => Err(OperatorLifecycleGrantError::Replayed),
|
|
Err(AuthError::Nip98Replay) => Err(OperatorLifecycleGrantError::Replayed),
|
|
Err(_) => Err(OperatorLifecycleGrantError::ReplayUnavailable),
|
|
}
|
|
}
|
|
|
|
async fn claim_server_quota(
|
|
guard: &dyn OperatorProofSetReplayGuard,
|
|
quota_scope: &str,
|
|
signer_attribution: &[u8; 32],
|
|
) -> Result<(), OperatorLifecycleGrantError> {
|
|
match guard.try_admit_quota(quota_scope, signer_attribution).await {
|
|
Ok(true) => Ok(()),
|
|
Ok(false) => Err(OperatorLifecycleGrantError::QuotaExceeded),
|
|
Err(_) => Err(OperatorLifecycleGrantError::QuotaUnavailable),
|
|
}
|
|
}
|
|
|
|
fn operator_attribution(public_key: PublicKey) -> [u8; 32] {
|
|
framed_digest(
|
|
b"buzz:configured-operator-attribution:v1",
|
|
&[&public_key.to_bytes()],
|
|
)
|
|
}
|
|
|
|
fn framed_digest(domain: &[u8], fields: &[&[u8]]) -> [u8; 32] {
|
|
let mut digest = Sha256::new();
|
|
digest.update((domain.len() as u64).to_be_bytes());
|
|
digest.update(domain);
|
|
for field in fields {
|
|
digest.update((field.len() as u64).to_be_bytes());
|
|
digest.update(field);
|
|
}
|
|
digest.finalize().into()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
collections::HashSet,
|
|
future::Future,
|
|
pin::Pin,
|
|
sync::{
|
|
atomic::{AtomicUsize, Ordering},
|
|
Mutex,
|
|
},
|
|
};
|
|
|
|
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
|
|
|
use super::*;
|
|
|
|
#[derive(Default)]
|
|
struct ReplaySet(Mutex<HashSet<(String, [u8; 32])>>);
|
|
|
|
impl OperatorProofSetReplayGuard for ReplaySet {
|
|
fn try_admit_quota<'a>(
|
|
&'a self,
|
|
_quota_scope: &'a str,
|
|
_signer_attribution: &'a [u8; 32],
|
|
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>> {
|
|
Box::pin(async { Ok(true) })
|
|
}
|
|
|
|
fn try_mark_all_in_scope<'a>(
|
|
&'a self,
|
|
scope: &'a str,
|
|
event_ids: &'a [nostr::EventId],
|
|
_ttl_secs: u64,
|
|
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>> {
|
|
Box::pin(async move {
|
|
let valid_cardinality = (1..=3).contains(&event_ids.len());
|
|
let distinct = event_ids
|
|
.iter()
|
|
.enumerate()
|
|
.all(|(index, event_id)| !event_ids[..index].contains(event_id));
|
|
if !valid_cardinality || !distinct {
|
|
return Err(AuthError::Internal(
|
|
"invalid test proof-set replay claim".to_owned(),
|
|
));
|
|
}
|
|
let mut seen = self.0.lock().expect("replay lock");
|
|
if event_ids
|
|
.iter()
|
|
.any(|event_id| seen.contains(&(scope.to_owned(), event_id.to_bytes())))
|
|
{
|
|
return Ok(false);
|
|
}
|
|
for event_id in event_ids {
|
|
seen.insert((scope.to_owned(), event_id.to_bytes()));
|
|
}
|
|
Ok(true)
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ReplaySet {
|
|
fn len(&self) -> usize {
|
|
self.0.lock().expect("replay lock").len()
|
|
}
|
|
}
|
|
|
|
struct QuotaFence {
|
|
quota_allowed: bool,
|
|
quota_unavailable: bool,
|
|
quota_scopes: Mutex<Vec<String>>,
|
|
replay_calls: AtomicUsize,
|
|
}
|
|
|
|
impl QuotaFence {
|
|
fn rejecting() -> Self {
|
|
Self {
|
|
quota_allowed: false,
|
|
quota_unavailable: false,
|
|
quota_scopes: Mutex::new(Vec::new()),
|
|
replay_calls: AtomicUsize::new(0),
|
|
}
|
|
}
|
|
|
|
fn unavailable() -> Self {
|
|
Self {
|
|
quota_allowed: false,
|
|
quota_unavailable: true,
|
|
quota_scopes: Mutex::new(Vec::new()),
|
|
replay_calls: AtomicUsize::new(0),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl OperatorProofSetReplayGuard for QuotaFence {
|
|
fn try_admit_quota<'a>(
|
|
&'a self,
|
|
quota_scope: &'a str,
|
|
_signer_attribution: &'a [u8; 32],
|
|
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>> {
|
|
Box::pin(async move {
|
|
self.quota_scopes
|
|
.lock()
|
|
.expect("quota scope lock")
|
|
.push(quota_scope.to_owned());
|
|
if self.quota_unavailable {
|
|
Err(AuthError::Internal("test quota unavailable".to_owned()))
|
|
} else {
|
|
Ok(self.quota_allowed)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn try_mark_all_in_scope<'a>(
|
|
&'a self,
|
|
_scope: &'a str,
|
|
_event_ids: &'a [EventId],
|
|
_ttl_secs: u64,
|
|
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>> {
|
|
Box::pin(async move {
|
|
self.replay_calls.fetch_add(1, Ordering::SeqCst);
|
|
Ok(true)
|
|
})
|
|
}
|
|
}
|
|
|
|
fn signed(keys: &Keys, url: &str, body: &[u8], intent_digest: [u8; 32]) -> String {
|
|
signed_at(keys, url, body, intent_digest, Timestamp::now())
|
|
}
|
|
|
|
fn signed_at(
|
|
keys: &Keys,
|
|
url: &str,
|
|
body: &[u8],
|
|
intent_digest: [u8; 32],
|
|
created_at: Timestamp,
|
|
) -> String {
|
|
let payload = hex::encode(<[u8; 32]>::from(Sha256::digest(body)));
|
|
signed_at_with_payloads(keys, url, body, intent_digest, created_at, &[payload])
|
|
}
|
|
|
|
fn signed_at_with_payloads(
|
|
keys: &Keys,
|
|
url: &str,
|
|
_body: &[u8],
|
|
intent_digest: [u8; 32],
|
|
created_at: Timestamp,
|
|
payloads: &[String],
|
|
) -> String {
|
|
let intent = hex::encode(intent_digest);
|
|
let mut tags = vec![
|
|
Tag::parse(["u", url]).expect("url tag"),
|
|
Tag::parse(["method", "POST"]).expect("method tag"),
|
|
];
|
|
tags.extend(
|
|
payloads
|
|
.iter()
|
|
.map(|payload| Tag::parse(["payload", payload.as_str()]).expect("payload tag")),
|
|
);
|
|
tags.push(Tag::parse(["buzz-intent", intent.as_str()]).expect("intent tag"));
|
|
let event = EventBuilder::new(Kind::HttpAuth, "")
|
|
.tags(tags)
|
|
.custom_created_at(created_at)
|
|
.sign_with_keys(keys)
|
|
.expect("sign NIP-98 proof");
|
|
serde_json::to_string(&event).expect("serialize NIP-98 proof")
|
|
}
|
|
|
|
fn lifecycle_body(
|
|
operation_id: Uuid,
|
|
intent_digest: [u8; 32],
|
|
replacement: Option<(&Keys, DateTime<Utc>)>,
|
|
) -> Vec<u8> {
|
|
let mut value = serde_json::json!({
|
|
"operation": {
|
|
"operation_id": operation_id,
|
|
"correlation_id": Uuid::new_v4(),
|
|
"attempt_id": Uuid::new_v4()
|
|
},
|
|
"expected_revision": 1,
|
|
"intent_digest_for_test": hex::encode(intent_digest),
|
|
"target": "redacted"
|
|
});
|
|
if let Some((keys, expires_at)) = replacement {
|
|
value["replacement"] = serde_json::json!({
|
|
"event_author_pubkey": hex::encode(keys.public_key().to_bytes()),
|
|
"expires_at": expires_at
|
|
});
|
|
}
|
|
serde_json::to_vec(&value).expect("serialize lifecycle body")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stock_lifecycle_policy_requires_one_configured_exact_proof() {
|
|
let signer = Keys::generate();
|
|
let verifier =
|
|
OperatorLifecycleVerifier::new(vec![signer.public_key()]).expect("configure verifier");
|
|
let replay = ReplaySet::default();
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let operation_id = Uuid::new_v4();
|
|
let intent_digest = [7; 32];
|
|
let body = lifecycle_body(operation_id, intent_digest, None);
|
|
let url = format!(
|
|
"https://operator.example/operator/communities/{}/identity-lifecycle/retire",
|
|
domain.as_uuid()
|
|
);
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signed(&signer, &url, &body, [6; 32]),
|
|
None,
|
|
None,
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Retire,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
None,
|
|
None,
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::InvalidCredential)
|
|
));
|
|
let grant = verifier
|
|
.verify_lifecycle(
|
|
&signed(&signer, &url, &body, intent_digest),
|
|
None,
|
|
None,
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Retire,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("verify grant");
|
|
assert_eq!(grant.authorization_domain(), domain);
|
|
assert_eq!(grant.operation_id(), operation_id);
|
|
assert_eq!(grant.action(), OperatorLifecycleAction::Retire);
|
|
assert_eq!(grant.approval_attribution(), None);
|
|
assert_ne!(grant.request_attribution(), [0; 32]);
|
|
assert_eq!(
|
|
format!("{grant:?}"),
|
|
"VerifiedOperatorLifecycleGrant([REDACTED])"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lifecycle_rejects_future_created_at_before_state_claims() {
|
|
let signer = Keys::generate();
|
|
let verifier =
|
|
OperatorLifecycleVerifier::new(vec![signer.public_key()]).expect("configure verifier");
|
|
let guard = QuotaFence::rejecting();
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let operation_id = Uuid::new_v4();
|
|
let intent_digest = [72; 32];
|
|
let body = lifecycle_body(operation_id, intent_digest, None);
|
|
let url = format!(
|
|
"https://operator.example/operator/communities/{}/identity-lifecycle/retire",
|
|
domain.as_uuid()
|
|
);
|
|
let future_created_at = Timestamp::from(
|
|
Timestamp::now()
|
|
.as_secs()
|
|
.saturating_add(NIP98_EXCLUSIVE_LIFETIME_SECONDS),
|
|
);
|
|
|
|
let rejection = verifier
|
|
.verify_lifecycle(
|
|
&signed_at(&signer, &url, &body, intent_digest, future_created_at),
|
|
None,
|
|
None,
|
|
&url,
|
|
&body,
|
|
&guard,
|
|
domain,
|
|
OperatorLifecycleAction::Retire,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("future-created proof must fail closed");
|
|
|
|
assert_eq!(
|
|
rejection,
|
|
OperatorLifecycleGrantError::InvalidCredential,
|
|
"future-created proof must retain the stable invalid-credential rejection"
|
|
);
|
|
assert!(
|
|
guard
|
|
.quota_scopes
|
|
.lock()
|
|
.expect("quota scope lock")
|
|
.is_empty(),
|
|
"future-created proof must not claim quota state"
|
|
);
|
|
assert_eq!(
|
|
guard.replay_calls.load(Ordering::SeqCst),
|
|
0,
|
|
"future-created proof must not claim replay state"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn allowlist_and_server_owned_quota_precede_replay_io() {
|
|
let signer = Keys::generate();
|
|
let outsider = Keys::generate();
|
|
let verifier =
|
|
OperatorLifecycleVerifier::new(vec![signer.public_key()]).expect("configure verifier");
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let operation_id = Uuid::new_v4();
|
|
let intent_digest = [71; 32];
|
|
let body = lifecycle_body(operation_id, intent_digest, None);
|
|
let url = format!(
|
|
"https://operator.example/operator/communities/{}/identity-lifecycle/retire",
|
|
domain.as_uuid()
|
|
);
|
|
|
|
let outsider_guard = QuotaFence::rejecting();
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signed(&outsider, &url, &body, intent_digest),
|
|
None,
|
|
None,
|
|
&url,
|
|
&body,
|
|
&outsider_guard,
|
|
domain,
|
|
OperatorLifecycleAction::Retire,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
None,
|
|
None,
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::Unauthenticated)
|
|
));
|
|
assert!(
|
|
outsider_guard
|
|
.quota_scopes
|
|
.lock()
|
|
.expect("quota scope lock")
|
|
.is_empty(),
|
|
"unconfigured signers must not consume a server quota"
|
|
);
|
|
assert_eq!(outsider_guard.replay_calls.load(Ordering::SeqCst), 0);
|
|
|
|
let quota_scope = format!("operator-quota:{}:retire", domain.as_uuid());
|
|
for (guard, expected) in [
|
|
(
|
|
QuotaFence::rejecting(),
|
|
OperatorLifecycleGrantError::QuotaExceeded,
|
|
),
|
|
(
|
|
QuotaFence::unavailable(),
|
|
OperatorLifecycleGrantError::QuotaUnavailable,
|
|
),
|
|
] {
|
|
assert_eq!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signed(&signer, &url, &body, intent_digest),
|
|
None,
|
|
None,
|
|
&url,
|
|
&body,
|
|
&guard,
|
|
domain,
|
|
OperatorLifecycleAction::Retire,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("quota denial must fail closed"),
|
|
expected
|
|
);
|
|
assert_eq!(
|
|
guard
|
|
.quota_scopes
|
|
.lock()
|
|
.expect("quota scope lock")
|
|
.as_slice(),
|
|
[quota_scope.as_str()]
|
|
);
|
|
assert_eq!(
|
|
guard.replay_calls.load(Ordering::SeqCst),
|
|
0,
|
|
"quota denial must not consume replay identities"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lifecycle_grant_rejects_self_approval_and_exact_replay() {
|
|
let signer = Keys::generate();
|
|
let approver = Keys::generate();
|
|
let verifier = OperatorLifecycleVerifier::requiring_independent_approval(vec![
|
|
signer.public_key(),
|
|
approver.public_key(),
|
|
])
|
|
.expect("configure verifier");
|
|
let replay = ReplaySet::default();
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let operation_id = Uuid::new_v4();
|
|
let intent_digest = [8; 32];
|
|
let body = lifecycle_body(operation_id, intent_digest, None);
|
|
let url = format!(
|
|
"https://operator.example/operator/communities/{}/identity-lifecycle/disable",
|
|
domain.as_uuid()
|
|
);
|
|
let signer_proof = signed(&signer, &url, &body, intent_digest);
|
|
let approver_proof = signed(&approver, &url, &body, intent_digest);
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signer_proof,
|
|
Some(&signer_proof),
|
|
None,
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Disable,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
None,
|
|
None,
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::ApprovalNotIndependent)
|
|
));
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signer_proof,
|
|
Some(&approver_proof),
|
|
None,
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Disable,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("first exact grant");
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signer_proof,
|
|
Some(&approver_proof),
|
|
None,
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Disable,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
None,
|
|
None,
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::Replayed)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_actions_require_exact_replacement_key_proof() {
|
|
let signer = Keys::generate();
|
|
let replacement = Keys::generate();
|
|
let verifier =
|
|
OperatorLifecycleVerifier::new(vec![signer.public_key()]).expect("configure verifier");
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let operation_id = Uuid::new_v4();
|
|
let intent_digest = [9; 32];
|
|
let replacement_expires_at = Utc::now() + chrono::Duration::seconds(30);
|
|
let body = lifecycle_body(
|
|
operation_id,
|
|
intent_digest,
|
|
Some((&replacement, replacement_expires_at)),
|
|
);
|
|
let url = format!(
|
|
"https://operator.example/operator/communities/{}/identity-lifecycle/rotate",
|
|
domain.as_uuid()
|
|
);
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signed(&signer, &url, &body, intent_digest),
|
|
None,
|
|
None,
|
|
&url,
|
|
&body,
|
|
&ReplaySet::default(),
|
|
domain,
|
|
OperatorLifecycleAction::Rotate,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
Some(replacement.public_key().to_bytes()),
|
|
Some(replacement_expires_at + chrono::Duration::seconds(1)),
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::InvalidRequest)
|
|
));
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signed(&signer, &url, &body, intent_digest),
|
|
None,
|
|
None,
|
|
&url,
|
|
&body,
|
|
&ReplaySet::default(),
|
|
domain,
|
|
OperatorLifecycleAction::Rotate,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
Some(replacement.public_key().to_bytes()),
|
|
Some(replacement_expires_at),
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::InvalidRequest)
|
|
));
|
|
let grant = verifier
|
|
.verify_lifecycle(
|
|
&signed(&signer, &url, &body, intent_digest),
|
|
None,
|
|
Some(&signed(&replacement, &url, &body, intent_digest)),
|
|
&url,
|
|
&body,
|
|
&ReplaySet::default(),
|
|
domain,
|
|
OperatorLifecycleAction::Rotate,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
Some(replacement.public_key().to_bytes()),
|
|
Some(replacement_expires_at),
|
|
)
|
|
.await
|
|
.expect("verify replacement-bound grant");
|
|
assert_eq!(
|
|
grant.replacement_event_author_pubkey(),
|
|
Some(replacement.public_key().to_bytes())
|
|
);
|
|
assert!(grant.replacement_proof_attribution().is_some());
|
|
assert_eq!(grant.replacement_expires_at(), Some(replacement_expires_at));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_complete_proof_sets_burn_no_replay_markers() {
|
|
let signer = Keys::generate();
|
|
let approver = Keys::generate();
|
|
let outsider = Keys::generate();
|
|
let replacement = Keys::generate();
|
|
let verifier = OperatorLifecycleVerifier::requiring_independent_approval(vec![
|
|
signer.public_key(),
|
|
approver.public_key(),
|
|
])
|
|
.expect("configure verifier");
|
|
let replay = ReplaySet::default();
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let operation_id = Uuid::new_v4();
|
|
let intent_digest = [31; 32];
|
|
let requested_expiry = Utc::now() + chrono::Duration::seconds(30);
|
|
let body = lifecycle_body(
|
|
operation_id,
|
|
intent_digest,
|
|
Some((&replacement, requested_expiry)),
|
|
);
|
|
let url = format!(
|
|
"https://operator.example/operator/communities/{}/identity-lifecycle/rotate",
|
|
domain.as_uuid()
|
|
);
|
|
let signer_proof = signed(&signer, &url, &body, intent_digest);
|
|
let approver_proof = signed(&approver, &url, &body, intent_digest);
|
|
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signer_proof,
|
|
Some(&signed(&outsider, &url, &body, intent_digest)),
|
|
Some(&signed(&replacement, &url, &body, intent_digest)),
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Rotate,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
Some(replacement.public_key().to_bytes()),
|
|
Some(requested_expiry),
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::Unauthenticated)
|
|
));
|
|
assert_eq!(replay.len(), 0, "bad approval must burn no marker");
|
|
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signer_proof,
|
|
Some(&approver_proof),
|
|
Some(&signed(&outsider, &url, &body, intent_digest)),
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Rotate,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
Some(replacement.public_key().to_bytes()),
|
|
Some(requested_expiry),
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::InvalidCredential)
|
|
));
|
|
assert_eq!(replay.len(), 0, "bad replacement must burn no marker");
|
|
|
|
let expired_replacement = signed_at(
|
|
&replacement,
|
|
&url,
|
|
&body,
|
|
intent_digest,
|
|
Timestamp::from(
|
|
u64::try_from(Utc::now().timestamp() - 120).expect("positive expired timestamp"),
|
|
),
|
|
);
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signer_proof,
|
|
Some(&approver_proof),
|
|
Some(&expired_replacement),
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Rotate,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
Some(replacement.public_key().to_bytes()),
|
|
Some(requested_expiry),
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::InvalidCredential)
|
|
));
|
|
assert_eq!(replay.len(), 0, "expired proof must burn no marker");
|
|
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signer_proof,
|
|
Some(&approver_proof),
|
|
Some(&signed(&replacement, &url, &body, intent_digest)),
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Rotate,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
Some(replacement.public_key().to_bytes()),
|
|
Some(requested_expiry),
|
|
)
|
|
.await
|
|
.expect("valid complete set remains claimable");
|
|
assert_eq!(replay.len(), 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn complete_proof_set_has_one_concurrent_winner() {
|
|
let signer = Keys::generate();
|
|
let approver = Keys::generate();
|
|
let replacement = Keys::generate();
|
|
let verifier = OperatorLifecycleVerifier::requiring_independent_approval(vec![
|
|
signer.public_key(),
|
|
approver.public_key(),
|
|
])
|
|
.expect("configure verifier");
|
|
let replay = ReplaySet::default();
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let operation_id = Uuid::new_v4();
|
|
let intent_digest = [32; 32];
|
|
let requested_expiry = Utc::now() + chrono::Duration::seconds(30);
|
|
let body = lifecycle_body(
|
|
operation_id,
|
|
intent_digest,
|
|
Some((&replacement, requested_expiry)),
|
|
);
|
|
let url = format!(
|
|
"https://operator.example/operator/communities/{}/identity-lifecycle/rotate",
|
|
domain.as_uuid()
|
|
);
|
|
let signer_proof = signed(&signer, &url, &body, intent_digest);
|
|
let approver_proof = signed(&approver, &url, &body, intent_digest);
|
|
let replacement_proof = signed(&replacement, &url, &body, intent_digest);
|
|
let verify = || {
|
|
verifier.verify_lifecycle(
|
|
&signer_proof,
|
|
Some(&approver_proof),
|
|
Some(&replacement_proof),
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Rotate,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
Some(replacement.public_key().to_bytes()),
|
|
Some(requested_expiry),
|
|
)
|
|
};
|
|
let (first, second) = tokio::join!(verify(), verify());
|
|
assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1);
|
|
assert!(matches!(
|
|
first.as_ref().err().or_else(|| second.as_ref().err()),
|
|
Some(OperatorLifecycleGrantError::Replayed)
|
|
));
|
|
assert_eq!(replay.len(), 3, "one winner claims the complete set");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lifecycle_url_is_byte_canonical_before_replay_io() {
|
|
let signer = Keys::generate();
|
|
let verifier =
|
|
OperatorLifecycleVerifier::new(vec![signer.public_key()]).expect("configure verifier");
|
|
let replay = ReplaySet::default();
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let operation_id = Uuid::new_v4();
|
|
let intent_digest = [33; 32];
|
|
let body = lifecycle_body(operation_id, intent_digest, None);
|
|
let path = format!(
|
|
"/operator/communities/{}/identity-lifecycle/retire",
|
|
domain.as_uuid()
|
|
);
|
|
let aliases = [
|
|
format!("http://operator.example{path}"),
|
|
format!("https://operator.example/ignored/..{path}"),
|
|
format!(
|
|
"https://operator.example/operator/communities/{}/identity-lifecycle/%72etire",
|
|
domain.as_uuid()
|
|
),
|
|
format!("https://OPERATOR.example{path}"),
|
|
format!("https://operator.example:443{path}"),
|
|
format!("https://user@operator.example{path}"),
|
|
format!("https://operator.example{path}?alias=true"),
|
|
format!("https://operator.example{path}#alias"),
|
|
];
|
|
for alias in aliases {
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signed(&signer, &alias, &body, intent_digest),
|
|
None,
|
|
None,
|
|
&alias,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Retire,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
None,
|
|
None,
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::InvalidRequest)
|
|
));
|
|
}
|
|
assert_eq!(replay.len(), 0);
|
|
let canonical = format!("https://operator.example{path}");
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signed(&signer, &canonical, &body, intent_digest),
|
|
None,
|
|
None,
|
|
&canonical,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Retire,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("exact canonical URL");
|
|
assert_eq!(replay.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lifecycle_requires_exactly_one_payload_tag_before_replay_io() {
|
|
let signer = Keys::generate();
|
|
let verifier =
|
|
OperatorLifecycleVerifier::new(vec![signer.public_key()]).expect("configure verifier");
|
|
let replay = ReplaySet::default();
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let operation_id = Uuid::new_v4();
|
|
let intent_digest = [34; 32];
|
|
let body = lifecycle_body(operation_id, intent_digest, None);
|
|
let url = format!(
|
|
"https://operator.example/operator/communities/{}/identity-lifecycle/retire",
|
|
domain.as_uuid()
|
|
);
|
|
let expected = hex::encode(<[u8; 32]>::from(Sha256::digest(&body)));
|
|
let conflicting = hex::encode([77_u8; 32]);
|
|
for payloads in [
|
|
Vec::<String>::new(),
|
|
vec![expected.clone(), expected.clone()],
|
|
vec![expected.clone(), conflicting.clone()],
|
|
vec![conflicting.clone(), expected.clone()],
|
|
] {
|
|
let proof = signed_at_with_payloads(
|
|
&signer,
|
|
&url,
|
|
&body,
|
|
intent_digest,
|
|
Timestamp::now(),
|
|
&payloads,
|
|
);
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&proof,
|
|
None,
|
|
None,
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Retire,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
None,
|
|
None,
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::InvalidCredential)
|
|
));
|
|
}
|
|
assert_eq!(replay.len(), 0);
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signed(&signer, &url, &body, intent_digest),
|
|
None,
|
|
None,
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Retire,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("one exact payload tag");
|
|
assert_eq!(replay.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_grant_expiry_is_the_exact_exclusive_minimum() {
|
|
let now_seconds = Utc::now().timestamp();
|
|
let now = DateTime::<Utc>::from_timestamp(now_seconds, 0).expect("current timestamp");
|
|
let cases = [
|
|
(0_i64, 0_i64, 0_i64, 10_i64, 10_i64),
|
|
(-20, -10, -5, 55, 40),
|
|
(-10, -20, -5, 55, 40),
|
|
(-10, -5, -20, 55, 40),
|
|
];
|
|
for (
|
|
signer_offset,
|
|
approver_offset,
|
|
replacement_offset,
|
|
requested_offset,
|
|
expected_offset,
|
|
) in cases
|
|
{
|
|
let signer = Keys::generate();
|
|
let approver = Keys::generate();
|
|
let replacement = Keys::generate();
|
|
let verifier = OperatorLifecycleVerifier::requiring_independent_approval(vec![
|
|
signer.public_key(),
|
|
approver.public_key(),
|
|
])
|
|
.expect("configure verifier");
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let operation_id = Uuid::new_v4();
|
|
let intent_digest = [35; 32];
|
|
let requested_expiry = now + chrono::Duration::seconds(requested_offset);
|
|
let body = lifecycle_body(
|
|
operation_id,
|
|
intent_digest,
|
|
Some((&replacement, requested_expiry)),
|
|
);
|
|
let url = format!(
|
|
"https://operator.example/operator/communities/{}/identity-lifecycle/rotate",
|
|
domain.as_uuid()
|
|
);
|
|
let proof_at = |keys: &Keys, offset: i64| {
|
|
signed_at(
|
|
keys,
|
|
&url,
|
|
&body,
|
|
intent_digest,
|
|
Timestamp::from(u64::try_from(now_seconds + offset).expect("positive time")),
|
|
)
|
|
};
|
|
let grant = verifier
|
|
.verify_lifecycle(
|
|
&proof_at(&signer, signer_offset),
|
|
Some(&proof_at(&approver, approver_offset)),
|
|
Some(&proof_at(&replacement, replacement_offset)),
|
|
&url,
|
|
&body,
|
|
&ReplaySet::default(),
|
|
domain,
|
|
OperatorLifecycleAction::Rotate,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
Some(replacement.public_key().to_bytes()),
|
|
Some(requested_expiry),
|
|
)
|
|
.await
|
|
.expect("verify bounded replacement grant");
|
|
assert_eq!(
|
|
grant.expires_at(),
|
|
now + chrono::Duration::seconds(expected_offset)
|
|
);
|
|
assert_eq!(grant.replacement_expires_at(), Some(requested_expiry));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn successor_expiry_at_authoritative_bound_denies_before_replay() {
|
|
let signer = Keys::generate();
|
|
let replacement = Keys::generate();
|
|
let verifier =
|
|
OperatorLifecycleVerifier::new(vec![signer.public_key()]).expect("configure verifier");
|
|
let replay = ReplaySet::default();
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let operation_id = Uuid::new_v4();
|
|
let intent_digest = [36; 32];
|
|
let at_bound =
|
|
DateTime::<Utc>::from_timestamp(Utc::now().timestamp(), 0).expect("current timestamp");
|
|
let body = lifecycle_body(operation_id, intent_digest, Some((&replacement, at_bound)));
|
|
let url = format!(
|
|
"https://operator.example/operator/communities/{}/identity-lifecycle/enable",
|
|
domain.as_uuid()
|
|
);
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_lifecycle(
|
|
&signed(&signer, &url, &body, intent_digest),
|
|
None,
|
|
Some(&signed(&replacement, &url, &body, intent_digest)),
|
|
&url,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
OperatorLifecycleAction::Enable,
|
|
operation_id,
|
|
intent_digest,
|
|
1,
|
|
Some(replacement.public_key().to_bytes()),
|
|
Some(at_bound),
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::InvalidRequest)
|
|
));
|
|
assert_eq!(replay.len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provision_intent_seals_domain_operation_policy_principal_and_key() {
|
|
let signer = Keys::generate();
|
|
let selected = Keys::generate();
|
|
let verifier =
|
|
OperatorLifecycleVerifier::new(vec![signer.public_key()]).expect("configure verifier");
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let operation_id = Uuid::new_v4();
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"authorization_domain": domain.as_uuid(),
|
|
"operation_id": operation_id,
|
|
"policy_revision": 7,
|
|
"policy_digest": hex::encode([21_u8; 32]),
|
|
"issuer": "https://issuer.example",
|
|
"subject": "opaque-subject",
|
|
"selected_event_author_pubkey": hex::encode(selected.public_key().to_bytes())
|
|
}))
|
|
.expect("serialize provision body");
|
|
let url = "https://operator.example/operator/identity-bindings/provision";
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_provision_binding(
|
|
&signed(&signer, url, &body, [1; 32]),
|
|
None,
|
|
url,
|
|
&body,
|
|
&ReplaySet::default(),
|
|
CommunityId::from_uuid(Uuid::new_v4()),
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::InvalidRequest)
|
|
));
|
|
let verified = verifier
|
|
.verify_provision_binding(
|
|
&signed(&signer, url, &body, [1; 32]),
|
|
None,
|
|
url,
|
|
&body,
|
|
&ReplaySet::default(),
|
|
domain,
|
|
)
|
|
.await
|
|
.expect("verify provision intent");
|
|
assert_eq!(verified.authorization_domain(), domain);
|
|
assert_eq!(verified.operation_id(), operation_id);
|
|
assert_eq!(verified.policy_revision(), 7);
|
|
assert_eq!(verified.policy_digest(), [21; 32]);
|
|
assert_eq!(
|
|
verified.selected_event_author_pubkey(),
|
|
selected.public_key().to_bytes()
|
|
);
|
|
assert_eq!(
|
|
verified.principal_storage_key().issuer(),
|
|
"https://issuer.example"
|
|
);
|
|
assert_eq!(verified.principal_storage_key().subject(), "opaque-subject");
|
|
assert_eq!(
|
|
format!("{verified:?}"),
|
|
"VerifiedProvisionBindingIntent([REDACTED])"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provision_rejects_noncanonical_route_before_replay() {
|
|
let signer = Keys::generate();
|
|
let selected = Keys::generate();
|
|
let verifier =
|
|
OperatorLifecycleVerifier::new(vec![signer.public_key()]).expect("configure verifier");
|
|
let domain = CommunityId::from_uuid(Uuid::new_v4());
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"authorization_domain": domain.as_uuid(),
|
|
"operation_id": Uuid::new_v4(),
|
|
"policy_revision": 7,
|
|
"policy_digest": hex::encode([21_u8; 32]),
|
|
"issuer": "https://issuer.example",
|
|
"subject": "opaque-subject",
|
|
"selected_event_author_pubkey": hex::encode(selected.public_key().to_bytes())
|
|
}))
|
|
.expect("serialize provision body");
|
|
let aliases = [
|
|
"http://operator.example/operator/identity-bindings/provision",
|
|
"https://operator.example/operator/identity-bindings/provision/",
|
|
"https://operator.example/operator/identity-bindings/provision?alias=true",
|
|
"https://operator.example/operator/identity-bindings/provision#alias",
|
|
"https://operator.example/operator/communities/provision",
|
|
"https://operator.example/operator/identity-bindings/other/../provision",
|
|
];
|
|
let replay = ReplaySet::default();
|
|
for alias in aliases {
|
|
assert!(matches!(
|
|
verifier
|
|
.verify_provision_binding(
|
|
&signed(&signer, alias, &body, [1; 32]),
|
|
None,
|
|
alias,
|
|
&body,
|
|
&replay,
|
|
domain,
|
|
)
|
|
.await,
|
|
Err(OperatorLifecycleGrantError::InvalidRequest)
|
|
));
|
|
}
|
|
assert_eq!(replay.len(), 0, "route rejection must precede replay I/O");
|
|
}
|
|
|
|
#[test]
|
|
fn opaque_authority_has_no_sensitive_debug_output() {
|
|
let first = Keys::generate().public_key();
|
|
let second = Keys::generate().public_key();
|
|
let verifier = OperatorLifecycleVerifier::new(vec![first]);
|
|
assert!(verifier.is_ok());
|
|
assert!(OperatorLifecycleVerifier::new(Vec::new()).is_err());
|
|
assert!(OperatorLifecycleVerifier::requiring_independent_approval(vec![first]).is_err());
|
|
let dual = OperatorLifecycleVerifier::requiring_independent_approval(vec![first, second])
|
|
.expect("independent policy");
|
|
assert!(dual.requires_independent_approval());
|
|
assert_ne!(
|
|
dual.approval_policy_digest(),
|
|
verifier.expect("stock policy").approval_policy_digest()
|
|
);
|
|
assert_eq!(OperatorLifecycleAction::Retire as i16, 3);
|
|
assert_eq!(OperatorLifecycleAction::Disable as i16, 4);
|
|
assert_eq!(OperatorLifecycleAction::Revoke as i16, 5);
|
|
assert_eq!(OperatorLifecycleAction::Rotate as i16, 6);
|
|
assert_eq!(OperatorLifecycleAction::Recover as i16, 7);
|
|
assert_eq!(OperatorLifecycleAction::Enable as i16, 8);
|
|
assert_eq!(
|
|
OperatorLifecycleAction::from_path_segment("admission-loss"),
|
|
None
|
|
);
|
|
}
|
|
}
|