use crate::crypto::{PublicKey, Signature};
use crate::error::{Error, Result};
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalPayload {
pub version: u8,
pub request_hash: [u8; 32],
pub nonce: [u8; 16],
pub external_id: String,
pub approved_at: u64,
pub expires_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extensions: Option<std::collections::HashMap<String, Vec<u8>>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedApproval {
pub approval_version: u8,
#[serde(with = "serde_bytes")]
pub payload: Vec<u8>,
pub approver_key: PublicKey,
pub signature: Signature,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalMetadata {
pub provider: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl SignedApproval {
pub fn create(payload: ApprovalPayload, keypair: &crate::crypto::SigningKey) -> Self {
let mut payload_bytes = Vec::new();
ciborium::into_writer(&payload, &mut payload_bytes)
.expect("Failed to serialize approval payload");
let preimage = Self::build_preimage(1, &payload_bytes);
let signature = keypair.sign(&preimage);
Self {
approval_version: 1,
payload: payload_bytes,
approver_key: keypair.public_key(),
signature,
}
}
pub fn verify(&self) -> Result<ApprovalPayload> {
if self.approval_version != 1 {
return Err(Error::UnsupportedVersion(self.approval_version));
}
let preimage = Self::build_preimage(self.approval_version, &self.payload);
self.approver_key.verify(&preimage, &self.signature)?;
let payload: ApprovalPayload = ciborium::from_reader(&self.payload[..])
.map_err(|e| Error::InvalidApproval(format!("Failed to deserialize payload: {}", e)))?;
if payload.version != 1 {
return Err(Error::UnsupportedVersion(payload.version));
}
let now = Utc::now().timestamp() as u64;
use crate::warrant::CLOCK_SKEW_TOLERANCE_SECS;
if payload.approved_at > now.saturating_add(CLOCK_SKEW_TOLERANCE_SECS) {
return Err(Error::InvalidApproval(format!(
"approval timestamp is in the future (skew > {}s)",
CLOCK_SKEW_TOLERANCE_SECS
)));
}
if payload.expires_at <= payload.approved_at {
return Err(Error::InvalidApproval(
"approval expires_at must be strictly greater than approved_at".to_string(),
));
}
Ok(payload)
}
pub fn to_cbor_b64(&self) -> Result<String> {
let mut buf = Vec::new();
ciborium::into_writer(self, &mut buf).map_err(|e| {
Error::InvalidApproval(format!("SignedApproval CBOR encode failed: {e}"))
})?;
Ok(base64::engine::general_purpose::STANDARD.encode(&buf))
}
pub fn matches_request(&self, request_hash: &[u8; 32]) -> Result<bool> {
let payload = self.verify()?;
Ok(&payload.request_hash == request_hash)
}
fn build_preimage(approval_version: u8, payload_bytes: &[u8]) -> Vec<u8> {
use crate::domain::APPROVAL_CONTEXT;
let mut preimage = Vec::with_capacity(APPROVAL_CONTEXT.len() + 1 + payload_bytes.len());
preimage.extend_from_slice(APPROVAL_CONTEXT);
preimage.push(approval_version);
preimage.extend_from_slice(payload_bytes);
preimage
}
}
impl ApprovalPayload {
pub fn new(
request_hash: [u8; 32],
nonce: [u8; 16],
external_id: String,
approved_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
) -> Self {
Self {
version: 1,
request_hash,
nonce,
external_id,
approved_at: approved_at.timestamp() as u64,
expires_at: expires_at.timestamp() as u64,
extensions: None,
}
}
pub fn matches_request(&self, request_hash: &[u8; 32]) -> bool {
&self.request_hash == request_hash
}
pub fn is_expired(&self) -> bool {
let now = Utc::now().timestamp() as u64;
now > self.expires_at
}
}
pub fn canonical_tool_args_cbor(
args: &std::collections::HashMap<String, crate::constraints::ConstraintValue>,
) -> Option<Vec<u8>> {
use std::collections::BTreeMap;
let sorted: BTreeMap<_, _> = args.iter().collect();
let mut cbor_buf = Vec::new();
if ciborium::into_writer(&sorted, &mut cbor_buf).is_ok() {
Some(cbor_buf)
} else {
None
}
}
pub fn compute_request_hash(
warrant_id: &str,
tool: &str,
args: &std::collections::HashMap<String, crate::constraints::ConstraintValue>,
authorized_holder: Option<&crate::crypto::PublicKey>,
) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(warrant_id.as_bytes());
hasher.update(b"|");
hasher.update(tool.as_bytes());
hasher.update(b"|");
if let Some(buf) = canonical_tool_args_cbor(args) {
hasher.update(&buf);
}
hasher.update(b"|");
if let Some(holder) = authorized_holder {
hasher.update(holder.to_bytes());
}
hasher.finalize().into()
}
pub fn build_approval_context_preimage(
warrant_id: &str,
tool: &str,
request_hash: &[u8; 32],
holder: &crate::crypto::PublicKey,
args_cbor: &[u8],
) -> Vec<u8> {
use crate::domain::{APPROVAL_CONTEXT_ATTESTATION, WARRANT_CONTEXT};
fn push_lp(out: &mut Vec<u8>, data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(data);
}
let mut out = Vec::new();
out.extend_from_slice(WARRANT_CONTEXT);
out.extend_from_slice(APPROVAL_CONTEXT_ATTESTATION);
out.push(1u8);
push_lp(&mut out, warrant_id.as_bytes());
push_lp(&mut out, tool.as_bytes());
push_lp(&mut out, request_hash);
push_lp(&mut out, &holder.to_bytes());
push_lp(&mut out, args_cbor);
out
}
pub fn build_approval_context_attestation(
signing_key: &crate::crypto::SigningKey,
warrant_id: &str,
tool: &str,
args: &std::collections::HashMap<String, crate::constraints::ConstraintValue>,
holder: &crate::crypto::PublicKey,
) -> Result<(String, ApprovalContextAttestationMeta)> {
let args_cbor = canonical_tool_args_cbor(args).ok_or_else(|| {
crate::error::Error::InvalidApproval("canonical CBOR encode of tool args failed".into())
})?;
let request_hash = compute_request_hash(warrant_id, tool, args, Some(holder));
let preimage =
build_approval_context_preimage(warrant_id, tool, &request_hash, holder, &args_cbor);
let signature = signing_key.sign_raw(&preimage);
let signer_pk = signing_key.public_key();
use base64::Engine;
let args_b64 = base64::engine::general_purpose::STANDARD.encode(&args_cbor);
let sig_b64 = base64::engine::general_purpose::STANDARD.encode(signature.to_bytes());
let meta = ApprovalContextAttestationMeta {
version: 1,
canonicalization: "cbor-canonical-v1".to_string(),
warrant_id: warrant_id.to_string(),
tool: tool.to_string(),
request_hash: hex::encode(request_hash),
holder_key_hex: hex::encode(holder.to_bytes()),
args_canonical_cbor_b64: args_b64.clone(),
signer_key_hex: hex::encode(signer_pk.to_bytes()),
signature_b64: sig_b64,
};
Ok((args_b64, meta))
}
pub fn verify_approval_context_attestation(
approver: &crate::crypto::PublicKey,
warrant_id: &str,
tool: &str,
args: &std::collections::HashMap<String, crate::constraints::ConstraintValue>,
holder: &crate::crypto::PublicKey,
signature: &crate::crypto::Signature,
) -> Result<()> {
let args_cbor = canonical_tool_args_cbor(args).ok_or_else(|| {
crate::error::Error::InvalidApproval("canonical CBOR encode of tool args failed".into())
})?;
let request_hash = compute_request_hash(warrant_id, tool, args, Some(holder));
let preimage =
build_approval_context_preimage(warrant_id, tool, &request_hash, holder, &args_cbor);
approver.verify_raw(&preimage, signature)
}
#[derive(Debug, Clone)]
pub struct ApprovalContextAttestationMeta {
pub version: u8,
pub canonicalization: String,
pub warrant_id: String,
pub tool: String,
pub request_hash: String,
pub holder_key_hex: String,
pub args_canonical_cbor_b64: String,
pub signer_key_hex: String,
pub signature_b64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRequest {
#[serde(with = "serde_bytes")]
pub request_id: [u8; 16],
pub warrant_id: String,
pub tool: String,
pub args: std::collections::BTreeMap<String, crate::constraints::ConstraintValue>,
pub request_hash: [u8; 32],
pub required_approvers: Vec<PublicKey>,
pub min_approvals: u32,
pub warrant_expires_at: u64,
pub created_at: u64,
}
impl ApprovalRequest {
pub fn new(
warrant_id: &str,
tool: &str,
args: &std::collections::HashMap<String, crate::constraints::ConstraintValue>,
request_hash: [u8; 32],
required_approvers: Vec<PublicKey>,
min_approvals: u32,
warrant_expires_at: u64,
) -> Self {
let request_id = uuid::Uuid::now_v7();
let now = Utc::now().timestamp() as u64;
Self {
request_id: *request_id.as_bytes(),
warrant_id: warrant_id.to_string(),
tool: tool.to_string(),
args: args.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
request_hash,
required_approvers,
min_approvals,
warrant_expires_at,
created_at: now,
}
}
}
use crate::domain::{REGISTRATION_PROOF_CONTEXT, ROTATION_PROOF_CONTEXT};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistrationProof {
pub signature: Signature,
pub timestamp: i64,
}
impl RegistrationProof {
pub fn create(
keypair: &crate::crypto::SigningKey,
provider: &str,
external_id: &str,
timestamp: i64,
) -> Self {
let payload = Self::build_payload(provider, external_id, &keypair.public_key(), timestamp);
let signature = keypair.sign(&payload);
Self {
signature,
timestamp,
}
}
pub fn verify(&self, public_key: &PublicKey, provider: &str, external_id: &str) -> Result<()> {
let payload = Self::build_payload(provider, external_id, public_key, self.timestamp);
public_key
.verify(&payload, &self.signature)
.map_err(|e| Error::InvalidApproval(format!("Invalid registration proof: {}", e)))
}
fn build_payload(
provider: &str,
external_id: &str,
public_key: &PublicKey,
timestamp: i64,
) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(REGISTRATION_PROOF_CONTEXT);
payload.extend_from_slice(b"|");
payload.extend_from_slice(provider.as_bytes());
payload.extend_from_slice(b"|");
payload.extend_from_slice(external_id.as_bytes());
payload.extend_from_slice(b"|");
payload.extend_from_slice(&public_key.to_bytes());
payload.extend_from_slice(b"|");
payload.extend_from_slice(×tamp.to_le_bytes());
payload
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RotationProof {
pub signature: Signature,
pub timestamp: i64,
}
impl RotationProof {
pub fn create(
old_keypair: &crate::crypto::SigningKey,
provider: &str,
external_id: &str,
new_key: &PublicKey,
timestamp: i64,
) -> Self {
let payload = Self::build_payload(provider, external_id, new_key, timestamp);
let signature = old_keypair.sign(&payload);
Self {
signature,
timestamp,
}
}
pub fn verify(
&self,
old_key: &PublicKey,
provider: &str,
external_id: &str,
new_key: &PublicKey,
) -> Result<()> {
let payload = Self::build_payload(provider, external_id, new_key, self.timestamp);
old_key
.verify(&payload, &self.signature)
.map_err(|e| Error::InvalidApproval(format!("Invalid rotation proof: {}", e)))
}
fn build_payload(
provider: &str,
external_id: &str,
new_key: &PublicKey,
timestamp: i64,
) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(ROTATION_PROOF_CONTEXT);
payload.extend_from_slice(b"|");
payload.extend_from_slice(provider.as_bytes());
payload.extend_from_slice(b"|");
payload.extend_from_slice(external_id.as_bytes());
payload.extend_from_slice(b"|");
payload.extend_from_slice(&new_key.to_bytes());
payload.extend_from_slice(b"|");
payload.extend_from_slice(×tamp.to_le_bytes());
payload
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Notary {
pub id: String,
pub name: Option<String>,
pub public_key: PublicKey,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployment_id: Option<String>,
}
impl Notary {
pub fn new(id: impl Into<String>, public_key: PublicKey) -> Self {
Self {
id: id.into(),
name: None,
public_key,
deployment_id: None,
}
}
pub fn with_deployment(mut self, deployment_id: impl Into<String>) -> Self {
self.deployment_id = Some(deployment_id.into());
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn id(&self) -> &str {
&self.id
}
pub fn deployment_id(&self) -> Option<&str> {
self.deployment_id.as_deref()
}
pub fn display_name(&self) -> &str {
self.name.as_deref().unwrap_or(&self.id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyBinding {
pub id: String,
pub external_id: String,
pub provider: String,
pub public_key: PublicKey,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployment_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub registered_by: String,
pub registered_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
pub active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<std::collections::BTreeMap<String, String>>,
}
impl KeyBinding {
pub fn is_valid(&self) -> bool {
if !self.active {
return false;
}
if let Some(expires) = self.expires_at {
if Utc::now() > expires {
return false;
}
}
true
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AuditEventType {
KeyRegistered,
KeyRotated,
KeyRevoked,
KeyExpired,
ApprovalGranted,
ApprovalVerified,
ApprovalFailed,
ProviderRegistered,
ProviderRemoved,
EnrollmentSuccess,
EnrollmentFailure,
WarrantIssued,
WarrantRevoked,
AuthorizationSuccess,
AuthorizationFailure,
VerificationFailed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub id: String,
pub event_type: AuditEventType,
pub timestamp: DateTime<Utc>,
pub provider: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_key_hex: Option<String>,
pub actor: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub related_ids: Option<Vec<String>>,
}
impl AuditEvent {
pub fn new(
event_type: AuditEventType,
provider: impl Into<String>,
actor: impl Into<String>,
) -> Self {
Self {
id: format!("evt_{}", uuid::Uuid::now_v7().simple()),
event_type,
timestamp: Utc::now(),
provider: provider.into(),
external_id: None,
public_key_hex: None,
actor: actor.into(),
details: None,
related_ids: None,
}
}
pub fn with_identity(mut self, external_id: impl Into<String>) -> Self {
self.external_id = Some(external_id.into());
self
}
pub fn with_key(mut self, key: &PublicKey) -> Self {
self.public_key_hex = Some(hex::encode(key.to_bytes()));
self
}
pub fn with_details(mut self, details: impl Into<String>) -> Self {
self.details = Some(details.into());
self
}
pub fn with_related(mut self, ids: Vec<String>) -> Self {
self.related_ids = Some(ids);
self
}
}
pub trait WarrantTracker {
fn track_warrant(&mut self, key: &PublicKey, warrant_id: &str);
}
#[derive(Debug, Default)]
pub struct NotaryRegistry {
providers: std::collections::HashMap<String, String>,
bindings: std::collections::HashMap<(String, String), KeyBinding>,
warrant_index: std::collections::HashMap<[u8; 32], std::collections::HashSet<String>>,
pending_events: Vec<AuditEvent>,
}
impl NotaryRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register_provider(
&mut self,
name: impl Into<String>,
description: impl Into<String>,
actor: impl Into<String>,
) {
let name = name.into();
let actor = actor.into();
self.providers.insert(name.clone(), description.into());
self.pending_events.push(
AuditEvent::new(AuditEventType::ProviderRegistered, &name, actor)
.with_details(format!("Provider '{}' registered", name)),
);
}
pub fn remove_provider(&mut self, name: &str, actor: impl Into<String>) {
let actor = actor.into();
let to_remove: Vec<_> = self
.bindings
.keys()
.filter(|(p, _)| p == name)
.cloned()
.collect();
for key in to_remove {
self.bindings.remove(&key);
}
self.providers.remove(name);
self.pending_events.push(
AuditEvent::new(AuditEventType::ProviderRemoved, name, actor)
.with_details(format!("Provider '{}' removed", name)),
);
}
pub fn has_provider(&self, name: &str) -> bool {
self.providers.contains_key(name)
}
pub fn list_providers(&self) -> Vec<(&str, &str)> {
self.providers
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect()
}
pub fn register_key(
&mut self,
binding: KeyBinding,
proof: &RegistrationProof,
notary: &Notary,
) -> Result<()> {
if !self.has_provider(&binding.provider) {
return Err(Error::UnknownProvider(binding.provider.clone()));
}
if let Some(notary_deployment) = ¬ary.deployment_id {
match &binding.deployment_id {
Some(binding_deployment) if binding_deployment != notary_deployment => {
return Err(Error::Unauthorized(format!(
"Notary scoped to '{}' cannot register key for '{}'",
notary_deployment, binding_deployment
)));
}
None => {
return Err(Error::Unauthorized(format!(
"Notary scoped to '{}' cannot register global key",
notary_deployment
)));
}
_ => {} }
}
proof.verify(&binding.public_key, &binding.provider, &binding.external_id)?;
let key = (binding.provider.clone(), binding.external_id.clone());
self.pending_events.push(
AuditEvent::new(
AuditEventType::KeyRegistered,
&binding.provider,
notary.id(),
)
.with_identity(&binding.external_id)
.with_key(&binding.public_key)
.with_details(format!(
"Key registered for '{}' by {} (PoP verified)",
binding
.display_name
.as_deref()
.unwrap_or(&binding.external_id),
notary.display_name()
)),
);
self.bindings.insert(key, binding);
Ok(())
}
pub fn rotate_key(
&mut self,
provider: &str,
external_id: &str,
new_key: PublicKey,
signature: &Signature,
actor: impl Into<String>,
) -> Result<()> {
let actor = actor.into();
let key = (provider.to_string(), external_id.to_string());
let binding = self
.bindings
.get_mut(&key)
.ok_or_else(|| Error::UnknownProvider(format!("{}:{}", provider, external_id)))?;
let mut message = Vec::new();
message.extend_from_slice(provider.as_bytes());
message.extend_from_slice(external_id.as_bytes());
message.extend_from_slice(&new_key.to_bytes());
binding
.public_key
.verify(&message, signature)
.map_err(|_| Error::SignatureInvalid("Invalid rotation signature".into()))?;
let old_key_hex = hex::encode(binding.public_key.to_bytes());
binding.public_key = new_key.clone();
self.pending_events.push(
AuditEvent::new(AuditEventType::KeyRotated, provider, actor)
.with_identity(external_id)
.with_key(&new_key)
.with_details(format!("Key rotated from {}", &old_key_hex[..16])),
);
Ok(())
}
pub fn revoke_key(
&mut self,
provider: &str,
external_id: &str,
reason: impl Into<String>,
signature: &Signature,
notary: &Notary,
) -> Result<Vec<String>> {
let reason = reason.into();
let key = (provider.to_string(), external_id.to_string());
let binding = self
.bindings
.get(&key)
.ok_or_else(|| Error::UnknownProvider(format!("{}:{}", provider, external_id)))?;
if let Some(notary_deployment) = ¬ary.deployment_id {
match &binding.deployment_id {
Some(binding_deployment) if binding_deployment != notary_deployment => {
return Err(Error::Unauthorized(format!(
"Notary scoped to '{}' cannot revoke key for '{}'",
notary_deployment, binding_deployment
)));
}
None => {
return Err(Error::Unauthorized(format!(
"Notary scoped to '{}' cannot revoke global key",
notary_deployment
)));
}
_ => {} }
}
let mut message = Vec::new();
message.extend_from_slice(provider.as_bytes());
message.extend_from_slice(external_id.as_bytes());
message.extend_from_slice(reason.as_bytes());
notary
.public_key
.verify(&message, signature)
.map_err(|_| Error::SignatureInvalid("Invalid revocation signature".into()))?;
let public_key = binding.public_key.clone();
let affected_warrants = self.get_warrants_for_key(&public_key);
let binding = self.bindings.get_mut(&key).unwrap();
binding.active = false;
self.pending_events.push(
AuditEvent::new(AuditEventType::KeyRevoked, provider, notary.id())
.with_identity(external_id)
.with_key(&public_key)
.with_details(format!(
"Revoked by {}: {}. Affected warrants: {}",
notary.display_name(),
reason,
affected_warrants.len()
)),
);
Ok(affected_warrants)
}
pub fn track_warrant(&mut self, key: &PublicKey, warrant_id: impl Into<String>) {
let key_bytes = key.to_bytes();
self.warrant_index
.entry(key_bytes)
.or_default()
.insert(warrant_id.into());
}
pub fn untrack_warrant(&mut self, key: &PublicKey, warrant_id: &str) {
let key_bytes = key.to_bytes();
if let Some(warrants) = self.warrant_index.get_mut(&key_bytes) {
warrants.remove(warrant_id);
if warrants.is_empty() {
self.warrant_index.remove(&key_bytes);
}
}
}
pub fn get_warrants_for_key(&self, key: &PublicKey) -> Vec<String> {
let key_bytes = key.to_bytes();
self.warrant_index
.get(&key_bytes)
.map(|set| set.iter().cloned().collect())
.unwrap_or_default()
}
pub fn resolve(&self, provider: &str, external_id: &str) -> Result<&PublicKey> {
let key = (provider.to_string(), external_id.to_string());
let binding = self
.bindings
.get(&key)
.ok_or_else(|| Error::UnknownProvider(format!("{}:{}", provider, external_id)))?;
if !binding.is_valid() {
return Err(Error::ApprovalExpired {
approved_at: binding.registered_at,
expired_at: binding.expires_at.unwrap_or(Utc::now()),
});
}
Ok(&binding.public_key)
}
pub fn get_binding(&self, provider: &str, external_id: &str) -> Option<&KeyBinding> {
let key = (provider.to_string(), external_id.to_string());
self.bindings.get(&key)
}
pub fn list_bindings(&self, provider: &str) -> Vec<&KeyBinding> {
self.bindings
.iter()
.filter(|((p, _), _)| p == provider)
.map(|(_, b)| b)
.collect()
}
pub fn drain_events(&mut self) -> Vec<AuditEvent> {
std::mem::take(&mut self.pending_events)
}
pub fn pending_event_count(&self) -> usize {
self.pending_events.len()
}
}
impl WarrantTracker for NotaryRegistry {
fn track_warrant(&mut self, key: &PublicKey, warrant_id: &str) {
self.track_warrant(key, warrant_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::SigningKey;
use std::collections::HashMap;
#[test]
fn test_request_hash_deterministic() {
let mut args1 = HashMap::new();
args1.insert(
"z".to_string(),
crate::constraints::ConstraintValue::String("last".to_string()),
);
args1.insert(
"a".to_string(),
crate::constraints::ConstraintValue::String("first".to_string()),
);
let mut args2 = HashMap::new();
args2.insert(
"a".to_string(),
crate::constraints::ConstraintValue::String("first".to_string()),
);
args2.insert(
"z".to_string(),
crate::constraints::ConstraintValue::String("last".to_string()),
);
let hash1 = compute_request_hash("wrt_123", "delete", &args1, None);
let hash2 = compute_request_hash("wrt_123", "delete", &args2, None);
assert_eq!(
hash1, hash2,
"Hash should be deterministic regardless of insertion order"
);
let holder = crate::crypto::SigningKey::generate();
let hash_with_holder =
compute_request_hash("wrt_123", "delete", &args1, Some(&holder.public_key()));
assert_ne!(
hash1, hash_with_holder,
"Hash should differ when holder is included"
);
}
#[test]
fn approval_context_attestation_round_trip() {
let signer = SigningKey::generate();
let holder = SigningKey::generate();
let mut args = HashMap::new();
args.insert(
"path".to_string(),
crate::constraints::ConstraintValue::String("/tmp/x".to_string()),
);
let (args_b64, meta) = build_approval_context_attestation(
&signer,
"wrt_test",
"read_file",
&args,
&holder.public_key(),
)
.expect("attestation");
assert_eq!(meta.warrant_id, "wrt_test");
assert_eq!(meta.tool, "read_file");
assert_eq!(meta.args_canonical_cbor_b64, args_b64);
let rh = compute_request_hash("wrt_test", "read_file", &args, Some(&holder.public_key()));
assert_eq!(meta.request_hash, hex::encode(rh));
use base64::Engine;
let sig_bytes = base64::engine::general_purpose::STANDARD
.decode(meta.signature_b64.as_bytes())
.expect("sig b64");
let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().unwrap();
let sig = crate::crypto::Signature::from_bytes(&sig_arr).expect("sig");
verify_approval_context_attestation(
&signer.public_key(),
"wrt_test",
"read_file",
&args,
&holder.public_key(),
&sig,
)
.expect("verify_approval_context_attestation");
}
#[test]
fn approval_context_attestation_verify_rejects_wrong_args() {
let signer = SigningKey::generate();
let holder = SigningKey::generate();
let mut args = HashMap::new();
args.insert(
"path".to_string(),
crate::constraints::ConstraintValue::String("/tmp/x".to_string()),
);
let (_args_b64, _meta) = build_approval_context_attestation(
&signer,
"wrt_test",
"read_file",
&args,
&holder.public_key(),
)
.expect("attestation");
let mut wrong_args = HashMap::new();
wrong_args.insert(
"path".to_string(),
crate::constraints::ConstraintValue::String("/other".to_string()),
);
let args_cbor_wrong = canonical_tool_args_cbor(&wrong_args).expect("cbor");
let rh_wrong = compute_request_hash(
"wrt_test",
"read_file",
&wrong_args,
Some(&holder.public_key()),
);
let preimage_wrong = build_approval_context_preimage(
"wrt_test",
"read_file",
&rh_wrong,
&holder.public_key(),
&args_cbor_wrong,
);
let sig_wrong = signer.sign_raw(&preimage_wrong);
assert!(
verify_approval_context_attestation(
&signer.public_key(),
"wrt_test",
"read_file",
&args,
&holder.public_key(),
&sig_wrong,
)
.is_err(),
"signature over different preimage must not verify for original args"
);
}
#[test]
fn test_request_hash_cbor_determinism() {
let mut args1 = HashMap::new();
args1.insert(
"amount".to_string(),
crate::constraints::ConstraintValue::Float(100.0),
);
let mut args2 = HashMap::new();
args2.insert(
"amount".to_string(),
crate::constraints::ConstraintValue::Float(100.0), );
let hash1 = compute_request_hash("wrt_123", "transfer", &args1, None);
let hash2 = compute_request_hash("wrt_123", "transfer", &args2, None);
assert_eq!(
hash1, hash2,
"CBOR should produce same hash for same float values"
);
let mut args_int1 = HashMap::new();
args_int1.insert(
"count".to_string(),
crate::constraints::ConstraintValue::Integer(42),
);
let mut args_int2 = HashMap::new();
args_int2.insert(
"count".to_string(),
crate::constraints::ConstraintValue::Integer(42),
);
let hash_int1 = compute_request_hash("wrt_456", "process", &args_int1, None);
let hash_int2 = compute_request_hash("wrt_456", "process", &args_int2, None);
assert_eq!(
hash_int1, hash_int2,
"CBOR should produce same hash for same integer values"
);
let mut args_float = HashMap::new();
args_float.insert(
"value".to_string(),
crate::constraints::ConstraintValue::Float(100.0),
);
let mut args_int = HashMap::new();
args_int.insert(
"value".to_string(),
crate::constraints::ConstraintValue::Integer(100),
);
let hash_float = compute_request_hash("wrt_789", "calc", &args_float, None);
let hash_int = compute_request_hash("wrt_789", "calc", &args_int, None);
assert_ne!(
hash_float, hash_int,
"CBOR should produce different hashes for float vs integer"
);
}
#[test]
fn test_notary_registry_lifecycle() {
let mut registry = NotaryRegistry::new();
let admin_keypair = SigningKey::generate();
let admin = Notary::new("admin-1", admin_keypair.public_key()).with_name("Test Admin");
registry.register_provider("aws-iam", "AWS IAM Provider", "admin-1");
assert!(registry.has_provider("aws-iam"));
let keypair = SigningKey::generate();
let binding = KeyBinding {
id: "kb_test_123".to_string(),
external_id: "arn:aws:iam::123:user/admin".to_string(),
provider: "aws-iam".to_string(),
public_key: keypair.public_key(),
deployment_id: None,
display_name: Some("Admin User".to_string()),
registered_by: admin.id().to_string(),
registered_at: Utc::now(),
expires_at: None,
active: true,
metadata: None,
};
let proof = RegistrationProof::create(
&keypair,
"aws-iam",
"arn:aws:iam::123:user/admin",
Utc::now().timestamp(),
);
registry.register_key(binding, &proof, &admin).unwrap();
let resolved = registry
.resolve("aws-iam", "arn:aws:iam::123:user/admin")
.unwrap();
assert_eq!(resolved.to_bytes(), keypair.public_key().to_bytes());
let events = registry.drain_events();
assert_eq!(events.len(), 2); assert_eq!(events[0].event_type, AuditEventType::ProviderRegistered);
assert_eq!(events[1].event_type, AuditEventType::KeyRegistered);
}
#[test]
fn test_key_rotation() {
let mut registry = NotaryRegistry::new();
let system_keypair = SigningKey::generate();
let system = Notary::new("system", system_keypair.public_key());
registry.register_provider("test", "Test Provider", "system");
let old_keypair = SigningKey::generate();
let new_keypair = SigningKey::generate();
let binding = KeyBinding {
id: "kb_rotate".to_string(),
external_id: "user@example.com".to_string(),
provider: "test".to_string(),
public_key: old_keypair.public_key(),
deployment_id: None,
display_name: None,
registered_by: system.id().to_string(),
registered_at: Utc::now(),
expires_at: None,
active: true,
metadata: None,
};
let reg_proof = RegistrationProof::create(
&old_keypair,
"test",
"user@example.com",
Utc::now().timestamp(),
);
registry.register_key(binding, ®_proof, &system).unwrap();
let mut message = Vec::new();
message.extend_from_slice(b"test");
message.extend_from_slice(b"user@example.com");
message.extend_from_slice(&new_keypair.public_key().to_bytes());
let rotation_sig = old_keypair.sign(&message);
registry
.rotate_key(
"test",
"user@example.com",
new_keypair.public_key(),
&rotation_sig,
"admin",
)
.unwrap();
let resolved = registry.resolve("test", "user@example.com").unwrap();
assert_eq!(resolved.to_bytes(), new_keypair.public_key().to_bytes());
let events = registry.drain_events();
assert!(events
.iter()
.any(|e| e.event_type == AuditEventType::KeyRotated));
}
#[test]
fn test_key_revocation() {
let mut registry = NotaryRegistry::new();
let system_keypair = SigningKey::generate();
let system = Notary::new("system", system_keypair.public_key());
let security_keypair = SigningKey::generate();
let security = Notary::new("security-team", security_keypair.public_key());
registry.register_provider("test", "Test Provider", "system");
let keypair = SigningKey::generate();
let binding = KeyBinding {
id: "kb_revoke".to_string(),
external_id: "user@example.com".to_string(),
provider: "test".to_string(),
public_key: keypair.public_key(),
deployment_id: None,
display_name: None,
registered_by: system.id().to_string(),
registered_at: Utc::now(),
expires_at: None,
active: true,
metadata: None,
};
let proof =
RegistrationProof::create(&keypair, "test", "user@example.com", Utc::now().timestamp());
registry.register_key(binding, &proof, &system).unwrap();
let reason = "Compromised";
let mut message = Vec::new();
message.extend_from_slice(b"test");
message.extend_from_slice(b"user@example.com");
message.extend_from_slice(reason.as_bytes());
let revoke_sig = security_keypair.sign(&message);
registry
.revoke_key("test", "user@example.com", reason, &revoke_sig, &security)
.unwrap();
let result = registry.resolve("test", "user@example.com");
assert!(result.is_err());
let binding = registry.get_binding("test", "user@example.com").unwrap();
assert!(!binding.active);
}
#[test]
fn test_unknown_provider_fails() {
let mut registry = NotaryRegistry::new();
let notary_keypair = SigningKey::generate();
let notary = Notary::new("test-notary", notary_keypair.public_key());
let keypair = SigningKey::generate();
let binding = KeyBinding {
id: "kb_fail".to_string(),
external_id: "user@example.com".to_string(),
provider: "unknown-provider".to_string(),
public_key: keypair.public_key(),
deployment_id: None,
display_name: None,
registered_by: notary.id().to_string(),
registered_at: Utc::now(),
expires_at: None,
active: true,
metadata: None,
};
let proof = RegistrationProof::create(
&keypair,
"unknown-provider",
"user@example.com",
Utc::now().timestamp(),
);
let result = registry.register_key(binding, &proof, ¬ary);
assert!(result.is_err());
}
#[test]
fn test_signed_approval_create_verify_roundtrip() {
let approver = SigningKey::generate();
let nonce: [u8; 16] = rand::random();
let now = Utc::now();
let payload = ApprovalPayload::new(
[0xAA; 32],
nonce,
"arn:aws:iam::123:user/admin".to_string(),
now,
now + chrono::Duration::seconds(300),
);
let signed = SignedApproval::create(payload, &approver);
let verified = signed.verify().expect("verify should succeed");
assert_eq!(verified.external_id, "arn:aws:iam::123:user/admin");
assert_eq!(verified.request_hash, [0xAA; 32]);
assert_eq!(verified.nonce, nonce);
}
#[test]
fn test_signed_approval_rejects_tampered_payload() {
let approver = SigningKey::generate();
let now = Utc::now();
let payload = ApprovalPayload::new(
[0xBB; 32],
rand::random(),
"user@corp.com".to_string(),
now,
now + chrono::Duration::seconds(300),
);
let mut signed = SignedApproval::create(payload, &approver);
if let Some(byte) = signed.payload.last_mut() {
*byte ^= 0xFF;
}
assert!(
signed.verify().is_err(),
"tampered payload must fail verification"
);
}
#[test]
fn test_signed_approval_rejects_wrong_key() {
let real_approver = SigningKey::generate();
let impersonator = SigningKey::generate();
let now = Utc::now();
let payload = ApprovalPayload::new(
[0xCC; 32],
rand::random(),
"victim@corp.com".to_string(),
now,
now + chrono::Duration::seconds(300),
);
let signed = SignedApproval::create(payload, &real_approver);
let forged = SignedApproval {
approver_key: impersonator.public_key(),
..signed
};
assert!(
forged.verify().is_err(),
"approval with wrong public key must fail verification"
);
}
#[test]
fn test_signed_approval_matches_request() {
let approver = SigningKey::generate();
let now = Utc::now();
let hash = [0xDD; 32];
let payload = ApprovalPayload::new(
hash,
rand::random(),
"approver@test.com".to_string(),
now,
now + chrono::Duration::seconds(300),
);
let signed = SignedApproval::create(payload, &approver);
assert!(signed.matches_request(&hash).unwrap());
assert!(!signed.matches_request(&[0xEE; 32]).unwrap());
}
#[test]
fn test_signed_approval_rejects_future_timestamp() {
let approver = SigningKey::generate();
let far_future = Utc::now() + chrono::Duration::hours(24);
let payload = ApprovalPayload::new(
[0x11; 32],
rand::random(),
"time-traveler@test.com".to_string(),
far_future,
far_future + chrono::Duration::seconds(300),
);
let signed = SignedApproval::create(payload, &approver);
let result = signed.verify();
assert!(
result.is_err(),
"approval with far-future approved_at must be rejected"
);
}
#[test]
fn test_signed_approval_rejects_bad_expiry_order() {
let approver = SigningKey::generate();
let now = Utc::now();
let payload = ApprovalPayload {
version: 1,
request_hash: [0x22; 32],
nonce: rand::random(),
external_id: "bad-expiry@test.com".to_string(),
approved_at: now.timestamp() as u64,
expires_at: now.timestamp() as u64, extensions: None,
};
let signed = SignedApproval::create(payload, &approver);
let result = signed.verify();
assert!(
result.is_err(),
"approval with expires_at <= approved_at must be rejected"
);
}
#[test]
fn test_domain_separation_prevents_cross_context_replay() {
let keypair = SigningKey::generate();
let now = Utc::now();
let payload = ApprovalPayload::new(
[0x33; 32],
rand::random(),
"user@test.com".to_string(),
now,
now + chrono::Duration::seconds(300),
);
let signed = SignedApproval::create(payload, &keypair);
let raw_sig = keypair.sign(&signed.payload);
let forged = SignedApproval {
approval_version: 1,
payload: signed.payload.clone(),
approver_key: keypair.public_key(),
signature: raw_sig,
};
assert!(
forged.verify().is_err(),
"signature over raw payload (without domain prefix) must fail"
);
}
#[test]
fn test_registration_proof_roundtrip() {
let keypair = SigningKey::generate();
let ts = Utc::now().timestamp();
let proof =
RegistrationProof::create(&keypair, "aws-iam", "arn:aws:iam::123:user/admin", ts);
assert!(proof
.verify(
&keypair.public_key(),
"aws-iam",
"arn:aws:iam::123:user/admin"
)
.is_ok());
assert!(proof
.verify(&keypair.public_key(), "okta", "arn:aws:iam::123:user/admin")
.is_err());
assert!(proof
.verify(
&keypair.public_key(),
"aws-iam",
"arn:aws:iam::999:user/evil"
)
.is_err());
let other = SigningKey::generate();
assert!(proof
.verify(
&other.public_key(),
"aws-iam",
"arn:aws:iam::123:user/admin"
)
.is_err());
}
#[test]
fn test_rotation_proof_roundtrip() {
let old_keypair = SigningKey::generate();
let new_keypair = SigningKey::generate();
let ts = Utc::now().timestamp();
let proof = RotationProof::create(
&old_keypair,
"test",
"user@test.com",
&new_keypair.public_key(),
ts,
);
assert!(proof
.verify(
&old_keypair.public_key(),
"test",
"user@test.com",
&new_keypair.public_key()
)
.is_ok());
let imposter = SigningKey::generate();
assert!(proof
.verify(
&imposter.public_key(),
"test",
"user@test.com",
&new_keypair.public_key()
)
.is_err());
let other_new = SigningKey::generate();
assert!(proof
.verify(
&old_keypair.public_key(),
"test",
"user@test.com",
&other_new.public_key()
)
.is_err());
}
}