use std::{collections::BTreeMap, fmt};
use exo_core::{Did, Hash256, Signature, Timestamp};
use exo_identity::{did_verification::verify_did_signature, registry::DidRegistry};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::error::{GatewayError, Result};
pub(crate) const FRESHNESS_WINDOW_MS: u64 = 300_000; const GATEWAY_AUTH_SIGNING_DOMAIN: &str = "exo.gateway.auth.request.v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Request {
pub actor_did: String,
pub action: String,
pub body_hash: Hash256,
pub signature: Signature,
pub timestamp: Timestamp,
}
#[derive(Debug, Clone)]
pub struct AuthenticatedActor {
pub did: Did,
pub authenticated_at: Timestamp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthenticationMetadata {
observed_at: Timestamp,
}
impl AuthenticationMetadata {
pub(crate) fn new(observed_at: Timestamp) -> Result<Self> {
if observed_at == Timestamp::ZERO {
return Err(GatewayError::BadRequest(
"authentication observed_at must be trusted gateway metadata and non-zero".into(),
));
}
Ok(Self { observed_at })
}
#[must_use]
pub fn observed_at(&self) -> Timestamp {
self.observed_at
}
}
#[derive(Serialize)]
struct RequestSigningPayload<'a> {
domain: &'static str,
actor_did: &'a str,
action: &'a str,
body_hash: Hash256,
request_timestamp_physical_ms: u64,
request_timestamp_logical: u32,
}
pub fn request_signing_payload(request: &Request) -> Result<Vec<u8>> {
let payload = RequestSigningPayload {
domain: GATEWAY_AUTH_SIGNING_DOMAIN,
actor_did: &request.actor_did,
action: &request.action,
body_hash: request.body_hash,
request_timestamp_physical_ms: request.timestamp.physical_ms,
request_timestamp_logical: request.timestamp.logical,
};
let mut encoded = Vec::new();
ciborium::into_writer(&payload, &mut encoded)
.map_err(|e| GatewayError::Internal(format!("gateway auth payload CBOR: {e:?}")))?;
Ok(encoded)
}
#[derive(Clone, Serialize, Deserialize)]
pub enum Credential {
DidSignature {
actor_did: String,
body_hash: Hash256,
signature: Signature,
timestamp: Timestamp,
},
ApiKey(Zeroizing<String>),
BearerToken(Zeroizing<String>),
}
impl fmt::Debug for Credential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DidSignature {
actor_did,
body_hash,
timestamp,
..
} => f
.debug_struct("DidSignature")
.field("actor_did", actor_did)
.field("body_hash", body_hash)
.field("timestamp", timestamp)
.field("signature", &"<redacted>")
.finish(),
Self::ApiKey(_) => f.debug_tuple("ApiKey").field(&"<redacted>").finish(),
Self::BearerToken(_) => f.debug_tuple("BearerToken").field(&"<redacted>").finish(),
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ApiKeyRecord {
pub key_hash: Hash256,
pub did: Did,
pub label: String,
pub created_at: Timestamp,
pub expires_at: Option<Timestamp>,
pub revoked: bool,
}
impl fmt::Debug for ApiKeyRecord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ApiKeyRecord")
.field("key_hash", &"<redacted>")
.field("did", &self.did)
.field("label", &self.label)
.field("created_at", &self.created_at)
.field("expires_at", &self.expires_at)
.field("revoked", &self.revoked)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ApiKeyMetadata {
pub created_at: Timestamp,
}
impl ApiKeyMetadata {
pub fn new(created_at: Timestamp) -> Result<Self> {
if created_at == Timestamp::ZERO {
return Err(GatewayError::BadRequest(
"API key created_at must be caller-supplied and non-zero".into(),
));
}
Ok(Self { created_at })
}
}
#[derive(Debug, Clone, Default)]
pub struct ApiKeyRegistry {
keys: BTreeMap<Hash256, ApiKeyRecord>,
}
impl ApiKeyRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register(
&mut self,
did: Did,
label: String,
metadata: ApiKeyMetadata,
) -> Result<(Zeroizing<String>, ApiKeyRecord)> {
self.register_with_entropy(did, label, metadata, |key_bytes| {
getrandom::getrandom(key_bytes)
.map_err(|error| GatewayError::Internal(format!("API key entropy failed: {error}")))
})
}
fn register_with_entropy<F>(
&mut self,
did: Did,
label: String,
metadata: ApiKeyMetadata,
fill_entropy: F,
) -> Result<(Zeroizing<String>, ApiKeyRecord)>
where
F: FnOnce(&mut [u8; 32]) -> Result<()>,
{
let mut key_bytes = Zeroizing::new([0u8; 32]);
fill_entropy(&mut key_bytes)?;
let plaintext_hex = Zeroizing::new(hex::encode(&key_bytes[..]));
let key_hash = Hash256::digest(&key_bytes[..]);
let record = ApiKeyRecord {
key_hash,
did,
label,
created_at: metadata.created_at,
expires_at: None,
revoked: false,
};
self.keys.insert(key_hash, record.clone());
Ok((plaintext_hex, record))
}
#[must_use]
pub fn resolve(&self, api_key: &str) -> Option<&ApiKeyRecord> {
let key_bytes = Zeroizing::new(hex::decode(api_key).ok()?);
let key_hash = Hash256::digest(&key_bytes[..]);
let mut matched = None;
for record in self.keys.values() {
if constant_time_eq(key_hash.as_bytes(), record.key_hash.as_bytes()) {
matched = Some(record);
}
}
matched
}
pub fn revoke(&mut self, key_hash: &Hash256) -> bool {
if let Some(record) = self.keys.get_mut(key_hash) {
record.revoked = true;
true
} else {
false
}
}
#[must_use]
pub fn keys_for_did(&self, did: &Did) -> Vec<&ApiKeyRecord> {
self.keys.values().filter(|r| r.did == *did).collect()
}
}
pub fn authenticate(
request: &Request,
registry: &dyn DidRegistry,
metadata: AuthenticationMetadata,
) -> Result<AuthenticatedActor> {
let did = Did::new(&request.actor_did).map_err(|_| GatewayError::AuthenticationFailed {
reason: "invalid DID".into(),
})?;
if request.signature.is_empty() {
return Err(GatewayError::AuthenticationFailed {
reason: "empty signature".into(),
});
}
check_freshness(&request.timestamp, &metadata.observed_at)?;
let doc = registry
.resolve(&did)
.ok_or_else(|| GatewayError::AuthenticationFailed {
reason: "DID not registered".into(),
})?;
let method = doc
.verification_methods
.iter()
.find(|m| m.active)
.ok_or_else(|| GatewayError::AuthenticationFailed {
reason: "no active verification method for DID".into(),
})?;
let signing_payload = request_signing_payload(request)?;
verify_did_signature(doc, &method.id, &signing_payload, &request.signature).map_err(|e| {
GatewayError::AuthenticationFailed {
reason: format!("signature verification failed: {e}"),
}
})?;
Ok(AuthenticatedActor {
did,
authenticated_at: metadata.observed_at,
})
}
pub fn resolve_credential(
credential: &Credential,
did_registry: &dyn DidRegistry,
api_key_registry: &ApiKeyRegistry,
metadata: AuthenticationMetadata,
) -> Result<AuthenticatedActor> {
match credential {
Credential::DidSignature {
actor_did,
body_hash,
signature,
timestamp,
} => {
let request = Request {
actor_did: actor_did.clone(),
action: String::new(),
body_hash: *body_hash,
signature: signature.clone(),
timestamp: *timestamp,
};
authenticate(&request, did_registry, metadata)
}
Credential::ApiKey(key) => {
resolve_token(key, did_registry, api_key_registry, "API key", metadata)
}
Credential::BearerToken(token) => resolve_token(
token,
did_registry,
api_key_registry,
"bearer token",
metadata,
),
}
}
fn resolve_token(
token: &str,
did_registry: &dyn DidRegistry,
registry: &ApiKeyRegistry,
kind: &str,
metadata: AuthenticationMetadata,
) -> Result<AuthenticatedActor> {
let record = registry
.resolve(token)
.ok_or_else(|| GatewayError::AuthenticationFailed {
reason: format!("unknown {kind}"),
})?;
if record.revoked {
return Err(GatewayError::AuthenticationFailed {
reason: format!("{kind} has been revoked"),
});
}
if let Some(expires_at) = record.expires_at {
if metadata.observed_at > expires_at {
return Err(GatewayError::AuthenticationFailed {
reason: format!("{kind} has expired"),
});
}
}
if did_registry.resolve(&record.did).is_none() {
return Err(GatewayError::AuthenticationFailed {
reason: format!("{kind} bound to unregistered or revoked DID"),
});
}
Ok(AuthenticatedActor {
did: record.did.clone(),
authenticated_at: metadata.observed_at,
})
}
fn check_freshness(ts: &Timestamp, observed_at: &Timestamp) -> Result<()> {
let now_ms = observed_at.physical_ms;
let req_ms = ts.physical_ms;
let skew_ms = now_ms.abs_diff(req_ms);
if skew_ms > FRESHNESS_WINDOW_MS {
return Err(GatewayError::AuthenticationFailed {
reason: format!(
"request timestamp outside freshness window: skew {skew_ms}ms (max {FRESHNESS_WINDOW_MS}ms)"
),
});
}
Ok(())
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
let mut diff = 0u8;
for idx in 0..left.len() {
diff |= left[idx] ^ right[idx];
}
diff == 0
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use exo_core::crypto::{generate_keypair, sign};
use exo_identity::{
did::{DidDocument, VerificationMethod},
registry::{DidRegistry, LocalDidRegistry},
};
use super::*;
fn req_ts() -> Timestamp {
Timestamp::new(10_000, 0)
}
fn auth_metadata() -> AuthenticationMetadata {
AuthenticationMetadata::new(Timestamp::new(10_000, 0)).unwrap()
}
fn api_key_metadata() -> ApiKeyMetadata {
ApiKeyMetadata::new(Timestamp::new(1_000, 0)).unwrap()
}
fn registry_with_alice() -> (LocalDidRegistry, exo_core::SecretKey) {
let did = Did::new("did:exo:alice").unwrap();
let (pk, sk) = generate_keypair();
let multibase = format!("z{}", bs58::encode(pk.as_bytes()).into_string());
let doc = DidDocument {
id: did.clone(),
public_keys: vec![pk],
authentication: vec![],
verification_methods: vec![VerificationMethod {
id: "did:exo:alice#key-1".into(),
key_type: "Ed25519VerificationKey2020".into(),
controller: did,
public_key_multibase: multibase,
version: 1,
active: true,
valid_from: 0,
revoked_at: None,
}],
hybrid_verification_methods: vec![],
service_endpoints: vec![],
created: Timestamp::ZERO,
updated: Timestamp::ZERO,
revoked: false,
};
let mut reg = LocalDidRegistry::new();
reg.register(doc).unwrap();
(reg, sk)
}
fn register_did(reg: &mut LocalDidRegistry, did_str: &str) {
let did = Did::new(did_str).unwrap();
let (pk, _sk) = generate_keypair();
let multibase = format!("z{}", bs58::encode(pk.as_bytes()).into_string());
let doc = DidDocument {
id: did.clone(),
public_keys: vec![pk],
authentication: vec![],
verification_methods: vec![VerificationMethod {
id: format!("{did_str}#key-1"),
key_type: "Ed25519VerificationKey2020".into(),
controller: did,
public_key_multibase: multibase,
version: 1,
active: true,
valid_from: 0,
revoked_at: None,
}],
hybrid_verification_methods: vec![],
service_endpoints: vec![],
created: Timestamp::ZERO,
updated: Timestamp::ZERO,
revoked: false,
};
reg.register(doc).unwrap();
}
struct StaticDidRegistry {
did: Did,
doc: DidDocument,
}
impl DidRegistry for StaticDidRegistry {
fn register(
&mut self,
_doc: DidDocument,
) -> std::result::Result<(), exo_identity::error::IdentityError> {
unreachable!("auth tests resolve only")
}
fn resolve(&self, did: &Did) -> Option<&DidDocument> {
if did == &self.did {
Some(&self.doc)
} else {
None
}
}
fn revoke(
&mut self,
_did: &Did,
_proof: &exo_identity::did::RevocationProof,
) -> std::result::Result<(), exo_identity::error::IdentityError> {
unreachable!("auth tests resolve only")
}
fn rotate_key(
&mut self,
_did: &Did,
_new_key: &exo_core::PublicKey,
_proof: &Signature,
_updated: Timestamp,
) -> std::result::Result<(), exo_identity::error::IdentityError> {
unreachable!("auth tests resolve only")
}
}
fn signed_request(mut request: Request, secret_key: &exo_core::SecretKey) -> Request {
request.signature = sign(&request_signing_payload(&request).unwrap(), secret_key);
request
}
#[test]
fn auth_valid() {
let (reg, sk) = registry_with_alice();
let body_hash = Hash256::ZERO;
let r = signed_request(
Request {
actor_did: "did:exo:alice".into(),
action: "read".into(),
body_hash,
signature: Signature::Empty,
timestamp: req_ts(),
},
&sk,
);
let a = authenticate(&r, ®, auth_metadata()).unwrap();
assert_eq!(a.did.as_str(), "did:exo:alice");
assert_eq!(a.authenticated_at, auth_metadata().observed_at());
}
#[test]
fn auth_rejects_action_changed_after_signing() {
let (reg, sk) = registry_with_alice();
let body_hash = Hash256::digest(b"same body, different action");
let mut request = signed_request(
Request {
actor_did: "did:exo:alice".into(),
action: "read".into(),
body_hash,
signature: Signature::Empty,
timestamp: req_ts(),
},
&sk,
);
request.action = "submit_governance_vote".into();
assert!(authenticate(&request, ®, auth_metadata()).is_err());
}
#[test]
fn auth_accepts_same_signed_request_with_independent_trusted_observation_inside_freshness() {
let (reg, sk) = registry_with_alice();
let body_hash = Hash256::digest(b"same body, gateway observed one millisecond later");
let request = signed_request(
Request {
actor_did: "did:exo:alice".into(),
action: "read".into(),
body_hash,
signature: Signature::Empty,
timestamp: req_ts(),
},
&sk,
);
let later_trusted_metadata =
AuthenticationMetadata::new(Timestamp::new(req_ts().physical_ms + 1, 0)).unwrap();
let actor = authenticate(&request, ®, later_trusted_metadata).unwrap();
assert_eq!(
actor.authenticated_at,
Timestamp::new(req_ts().physical_ms + 1, 0)
);
}
#[test]
fn auth_rejects_replayed_request_against_trusted_observation_time() {
let (reg, sk) = registry_with_alice();
let request = signed_request(
Request {
actor_did: "did:exo:alice".into(),
action: "read".into(),
body_hash: Hash256::digest(b"old signed request"),
signature: Signature::Empty,
timestamp: req_ts(),
},
&sk,
);
let trusted_later = AuthenticationMetadata::new(Timestamp::new(
req_ts().physical_ms + FRESHNESS_WINDOW_MS + 1,
0,
))
.unwrap();
let error = authenticate(&request, ®, trusted_later).unwrap_err();
assert!(
error.to_string().contains("freshness window"),
"replayed request must fail against trusted gateway observation time: {error}"
);
}
#[test]
fn auth_rejects_timestamp_changed_after_signing() {
let (reg, sk) = registry_with_alice();
let body_hash = Hash256::digest(b"same body, different request timestamp");
let mut request = signed_request(
Request {
actor_did: "did:exo:alice".into(),
action: "read".into(),
body_hash,
signature: Signature::Empty,
timestamp: req_ts(),
},
&sk,
);
request.timestamp = Timestamp::new(req_ts().physical_ms + 1, 0);
assert!(authenticate(&request, ®, auth_metadata()).is_err());
}
#[test]
fn auth_rejects_body_hash_changed_after_signing() {
let (reg, sk) = registry_with_alice();
let mut request = signed_request(
Request {
actor_did: "did:exo:alice".into(),
action: "read".into(),
body_hash: Hash256::digest(b"original body"),
signature: Signature::Empty,
timestamp: req_ts(),
},
&sk,
);
request.body_hash = Hash256::digest(b"tampered body");
assert!(authenticate(&request, ®, auth_metadata()).is_err());
}
#[test]
fn request_signing_payload_is_domain_separated_cbor() {
#[derive(Deserialize)]
struct DecodedPayload {
domain: String,
actor_did: String,
action: String,
body_hash: Hash256,
request_timestamp_physical_ms: u64,
request_timestamp_logical: u32,
}
let body_hash = Hash256::digest(b"canonical body hash");
let request = Request {
actor_did: "did:exo:alice".into(),
action: "read".to_owned(),
body_hash,
signature: Signature::Empty,
timestamp: req_ts(),
};
let payload = request_signing_payload(&request).unwrap();
let decoded: DecodedPayload = ciborium::from_reader(payload.as_slice()).unwrap();
assert_eq!(decoded.domain, GATEWAY_AUTH_SIGNING_DOMAIN);
assert_eq!(decoded.actor_did, "did:exo:alice");
assert_eq!(decoded.action, "read");
assert_eq!(decoded.body_hash, body_hash);
assert_eq!(decoded.request_timestamp_physical_ms, req_ts().physical_ms);
assert_eq!(decoded.request_timestamp_logical, req_ts().logical);
}
#[test]
fn auth_rejects_body_hash_only_signature_for_action_request() {
let (reg, sk) = registry_with_alice();
let body_hash = Hash256::digest(b"same body, different action");
let signature = sign(body_hash.as_bytes(), &sk);
let request = Request {
actor_did: "did:exo:alice".into(),
action: "submit_governance_vote".into(),
body_hash,
signature,
timestamp: req_ts(),
};
assert!(authenticate(&request, ®, auth_metadata()).is_err());
}
#[test]
fn auth_rejects_active_method_not_bound_to_document_key() {
let did = Did::new("did:exo:alice").unwrap();
let (declared_pk, _) = generate_keypair();
let (method_pk, method_sk) = generate_keypair();
let method_multibase = format!("z{}", bs58::encode(method_pk.as_bytes()).into_string());
let doc = DidDocument {
id: did.clone(),
public_keys: vec![declared_pk],
authentication: vec![],
verification_methods: vec![VerificationMethod {
id: "did:exo:alice#key-1".into(),
key_type: "Ed25519VerificationKey2020".into(),
controller: did.clone(),
public_key_multibase: method_multibase,
version: 1,
active: true,
valid_from: 0,
revoked_at: None,
}],
hybrid_verification_methods: vec![],
service_endpoints: vec![],
created: Timestamp::ZERO,
updated: Timestamp::ZERO,
revoked: false,
};
let reg = StaticDidRegistry {
did: did.clone(),
doc,
};
let request = signed_request(
Request {
actor_did: did.as_str().to_owned(),
action: "read".into(),
body_hash: Hash256::digest(b"body"),
signature: Signature::Empty,
timestamp: req_ts(),
},
&method_sk,
);
let err = authenticate(&request, ®, auth_metadata()).unwrap_err();
assert!(
err.to_string().contains("not declared"),
"auth must fail closed when a custom registry returns an unbound active method: {err}"
);
}
#[test]
fn auth_invalid_did() {
let (reg, _) = registry_with_alice();
let r = Request {
actor_did: "bad".into(),
action: "read".into(),
body_hash: Hash256::ZERO,
signature: Signature::from_bytes({
let mut s = [0u8; 64];
s[0] = 1;
s
}),
timestamp: req_ts(),
};
assert!(authenticate(&r, ®, auth_metadata()).is_err());
}
#[test]
fn authentication_failure_messages_do_not_echo_request_dids() {
let (reg, _) = registry_with_alice();
let signature = Signature::from_bytes({
let mut s = [0u8; 64];
s[0] = 1;
s
});
let invalid_raw_did = "not-a-did-private-identifier";
let invalid_request = Request {
actor_did: invalid_raw_did.into(),
action: "read".into(),
body_hash: Hash256::ZERO,
signature: signature.clone(),
timestamp: req_ts(),
};
let invalid_error = authenticate(&invalid_request, ®, auth_metadata())
.expect_err("invalid DID must be rejected")
.to_string();
assert!(
!invalid_error.contains(invalid_raw_did),
"authentication errors must not echo malformed DID input: {invalid_error}"
);
let unregistered_did = "did:exo:privacy-sensitive-subject";
let unregistered_request = Request {
actor_did: unregistered_did.into(),
action: "read".into(),
body_hash: Hash256::ZERO,
signature,
timestamp: req_ts(),
};
let unregistered_error = authenticate(&unregistered_request, ®, auth_metadata())
.expect_err("unknown DID must be rejected")
.to_string();
assert!(
!unregistered_error.contains(unregistered_did),
"authentication errors must not echo unknown DIDs: {unregistered_error}"
);
}
#[test]
fn auth_empty_sig() {
let (reg, _) = registry_with_alice();
let r = Request {
actor_did: "did:exo:alice".into(),
action: "read".into(),
body_hash: Hash256::ZERO,
signature: Signature::from_bytes([0u8; 64]),
timestamp: req_ts(),
};
assert!(authenticate(&r, ®, auth_metadata()).is_err());
}
#[test]
fn auth_empty_sig_variant() {
let (reg, _) = registry_with_alice();
let r = Request {
actor_did: "did:exo:alice".into(),
action: "read".into(),
body_hash: Hash256::ZERO,
signature: Signature::Empty,
timestamp: req_ts(),
};
assert!(authenticate(&r, ®, auth_metadata()).is_err());
}
#[test]
fn auth_wrong_signature_fails() {
let (reg, _sk) = registry_with_alice();
let (_pk2, sk2) = generate_keypair();
let body_hash = Hash256::ZERO;
let bad_sig = sign(body_hash.as_bytes(), &sk2);
let r = Request {
actor_did: "did:exo:alice".into(),
action: "read".into(),
body_hash,
signature: bad_sig,
timestamp: req_ts(),
};
assert!(authenticate(&r, ®, auth_metadata()).is_err());
}
#[test]
fn auth_did_not_registered_fails() {
let (reg, _) = registry_with_alice();
let (_, sk_bob) = generate_keypair();
let body_hash = Hash256::ZERO;
let sig = sign(body_hash.as_bytes(), &sk_bob);
let r = Request {
actor_did: "did:exo:bob".into(),
action: "read".into(),
body_hash,
signature: sig,
timestamp: req_ts(),
};
assert!(authenticate(&r, ®, auth_metadata()).is_err());
}
#[test]
fn request_serde() {
let r = Request {
actor_did: "did:exo:a".into(),
action: "r".into(),
body_hash: Hash256::ZERO,
signature: Signature::from_bytes({
let mut s = [0u8; 64];
s[0] = 1;
s
}),
timestamp: req_ts(),
};
let j = serde_json::to_string(&r).unwrap();
assert!(!j.is_empty());
}
#[test]
fn freshness_check_passes_recent() {
let observed_at = Timestamp::new(10_000, 0);
assert!(check_freshness(&observed_at, &observed_at).is_ok());
}
#[test]
fn freshness_check_rejects_stale() {
let stale = Timestamp::new(1, 0);
let observed_at = Timestamp::new(FRESHNESS_WINDOW_MS + 2, 0);
assert!(check_freshness(&stale, &observed_at).is_err());
}
#[test]
fn authentication_metadata_rejects_zero_observed_at() {
let metadata = AuthenticationMetadata::new(Timestamp::ZERO);
assert!(
matches!(metadata, Err(GatewayError::BadRequest(reason)) if reason.contains("observed_at"))
);
}
#[test]
fn authenticate_rejects_stale_against_trusted_metadata() {
let (reg, sk) = registry_with_alice();
let body_hash = Hash256::ZERO;
let signature = sign(body_hash.as_bytes(), &sk);
let r = Request {
actor_did: "did:exo:alice".into(),
action: "read".into(),
body_hash,
signature,
timestamp: Timestamp::new(1, 0),
};
let metadata =
AuthenticationMetadata::new(Timestamp::new(FRESHNESS_WINDOW_MS + 2, 0)).unwrap();
let err = authenticate(&r, ®, metadata).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("freshness window"),
"expected freshness-window error in: {msg}"
);
}
#[test]
fn auth_production_does_not_fabricate_auth_timestamps() {
let source = include_str!("auth.rs");
let production = source
.split("#[cfg(test)]")
.next()
.expect("production section");
let forbidden_timestamp = ["Timestamp", "::now_utc"].concat();
assert!(
!production.contains(&forbidden_timestamp),
"gateway auth must use trusted gateway authentication timestamps"
);
let forbidden_system_time = ["SystemTime", "::now"].concat();
assert!(
!production.contains(&forbidden_system_time),
"gateway auth must not read wall-clock time"
);
assert!(
!production.contains("pub observed_at"),
"authentication observation time must not be a public caller-controlled field"
);
assert!(
!production.contains("observed_at_physical_ms"),
"DID request signatures must not bind caller-supplied observation time"
);
}
#[test]
fn auth_production_does_not_verify_raw_body_hash_signatures() {
let source = include_str!("auth.rs");
let production = source
.split("#[cfg(test)]")
.next()
.expect("production section");
assert!(
!production.contains("request.body_hash.as_bytes()"),
"gateway auth must bind DID signatures to the canonical request envelope"
);
assert!(
production.contains("request_signing_payload(request)"),
"gateway auth must route signature verification through the canonical payload helper"
);
}
#[test]
fn auth_production_does_not_format_request_dids_into_errors() {
let source = include_str!("auth.rs");
let production = source
.split("#[cfg(test)]")
.next()
.expect("production section");
for pattern in [
r#"format!("invalid DID: {}", request.actor_did)"#,
r#"format!("DID not registered: {}", request.actor_did)"#,
r#"format!("no active verification method for DID: {}", request.actor_did)"#,
] {
assert!(
!production.contains(pattern),
"authentication diagnostics must not format raw request DIDs: {pattern}"
);
}
}
#[test]
fn credential_did_signature_valid() {
let (reg, sk) = registry_with_alice();
let body_hash = Hash256::ZERO;
let request = signed_request(
Request {
actor_did: "did:exo:alice".into(),
action: String::new(),
body_hash,
signature: Signature::Empty,
timestamp: req_ts(),
},
&sk,
);
let cred = Credential::DidSignature {
actor_did: "did:exo:alice".into(),
body_hash,
signature: request.signature,
timestamp: req_ts(),
};
let api_reg = ApiKeyRegistry::new();
let actor = resolve_credential(&cred, ®, &api_reg, auth_metadata()).unwrap();
assert_eq!(actor.did.as_str(), "did:exo:alice");
}
#[test]
fn credential_did_signature_invalid() {
let (reg, _sk) = registry_with_alice();
let (_pk2, sk2) = generate_keypair();
let body_hash = Hash256::ZERO;
let bad_sig = sign(body_hash.as_bytes(), &sk2);
let cred = Credential::DidSignature {
actor_did: "did:exo:alice".into(),
body_hash,
signature: bad_sig,
timestamp: req_ts(),
};
let api_reg = ApiKeyRegistry::new();
assert!(resolve_credential(&cred, ®, &api_reg, auth_metadata()).is_err());
}
#[test]
fn credential_api_key_valid() {
let mut did_reg = LocalDidRegistry::new();
register_did(&mut did_reg, "did:exo:alice");
let mut api_reg = ApiKeyRegistry::new();
let did = Did::new("did:exo:alice").unwrap();
let (plaintext, _record) = api_reg
.register(did, "test key".into(), api_key_metadata())
.expect("api key registration");
let cred = Credential::ApiKey(plaintext);
let actor = resolve_credential(&cred, &did_reg, &api_reg, auth_metadata()).unwrap();
assert_eq!(actor.did.as_str(), "did:exo:alice");
}
#[test]
fn credential_api_key_with_revoked_did_fails() {
let (reg, _sk) = registry_with_alice();
let mut api_reg = ApiKeyRegistry::new();
let revoked_did = Did::new("did:exo:revoked-subject").unwrap();
let (plaintext, _record) = api_reg
.register(revoked_did, "test key".into(), api_key_metadata())
.expect("api key registration");
let cred = Credential::ApiKey(plaintext);
let err = resolve_credential(&cred, ®, &api_reg, auth_metadata()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("unregistered or revoked DID"),
"expected revoked-DID rejection in: {msg}"
);
}
#[test]
fn api_key_registry_resolve_does_not_use_tree_lookup_for_secret_hash() {
let source = include_str!("auth.rs");
let resolve_start = source
.find("pub fn resolve(&self, api_key: &str)")
.expect("resolve source exists");
let resolve_end = source[resolve_start..]
.find("/// Revoke a key")
.expect("revoke marker exists");
let resolve_body = &source[resolve_start..resolve_start + resolve_end];
let forbidden = [".keys", ".get(&key_hash)"].concat();
assert!(
!resolve_body.contains(&forbidden),
"API key resolution must not branch through a tree lookup on secret-derived hashes"
);
assert!(
resolve_body.contains("constant_time_eq"),
"API key resolution must compare stored hashes in constant time"
);
}
#[test]
fn credential_api_key_revoked() {
let did_reg = LocalDidRegistry::new();
let mut api_reg = ApiKeyRegistry::new();
let did = Did::new("did:exo:alice").unwrap();
let (plaintext, record) = api_reg
.register(did, "test key".into(), api_key_metadata())
.expect("api key registration");
api_reg.revoke(&record.key_hash);
let cred = Credential::ApiKey(plaintext);
let err = resolve_credential(&cred, &did_reg, &api_reg, auth_metadata()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("revoked"), "expected 'revoked' in: {msg}");
}
#[test]
fn credential_api_key_expired() {
let did_reg = LocalDidRegistry::new();
let mut api_reg = ApiKeyRegistry::new();
let did = Did::new("did:exo:alice").unwrap();
let (plaintext, record) = api_reg
.register(did, "test key".into(), api_key_metadata())
.expect("api key registration");
let key_hash = record.key_hash;
api_reg.keys.get_mut(&key_hash).unwrap().expires_at = Some(Timestamp::new(1, 0));
let cred = Credential::ApiKey(plaintext);
let err = resolve_credential(&cred, &did_reg, &api_reg, auth_metadata()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("expired"), "expected 'expired' in: {msg}");
}
#[test]
fn credential_api_key_unknown() {
let did_reg = LocalDidRegistry::new();
let api_reg = ApiKeyRegistry::new();
let cred = Credential::ApiKey("deadbeef".repeat(8).into());
let err = resolve_credential(&cred, &did_reg, &api_reg, auth_metadata()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("unknown"), "expected 'unknown' in: {msg}");
}
#[test]
fn credential_bearer_valid() {
let mut did_reg = LocalDidRegistry::new();
register_did(&mut did_reg, "did:exo:bob");
let mut api_reg = ApiKeyRegistry::new();
let did = Did::new("did:exo:bob").unwrap();
let (plaintext, _record) = api_reg
.register(did, "bearer session".into(), api_key_metadata())
.expect("api key registration");
let cred = Credential::BearerToken(plaintext);
let actor = resolve_credential(&cred, &did_reg, &api_reg, auth_metadata()).unwrap();
assert_eq!(actor.did.as_str(), "did:exo:bob");
}
#[test]
fn api_key_registry_register() {
let mut reg = ApiKeyRegistry::new();
let did = Did::new("did:exo:carol").unwrap();
let (plaintext, record) = reg
.register(did.clone(), "my key".into(), api_key_metadata())
.expect("api key registration");
assert_eq!(plaintext.len(), 64);
assert!(hex::decode(plaintext.as_str()).is_ok());
assert_eq!(record.did, did);
assert_eq!(record.label, "my key");
assert!(!record.revoked);
assert!(record.expires_at.is_none());
assert_eq!(reg.keys.len(), 1);
}
#[test]
fn api_key_registry_revoke() {
let mut reg = ApiKeyRegistry::new();
let did = Did::new("did:exo:carol").unwrap();
let (_plaintext, record) = reg
.register(did, "my key".into(), api_key_metadata())
.expect("api key registration");
assert!(reg.revoke(&record.key_hash));
assert!(reg.keys.get(&record.key_hash).unwrap().revoked);
assert!(!reg.revoke(&Hash256::ZERO));
}
#[test]
fn api_key_registry_keys_for_did() {
let mut reg = ApiKeyRegistry::new();
let alice = Did::new("did:exo:alice").unwrap();
let bob = Did::new("did:exo:bob").unwrap();
reg.register(alice.clone(), "alice-1".into(), api_key_metadata())
.expect("api key registration");
reg.register(alice.clone(), "alice-2".into(), api_key_metadata())
.expect("api key registration");
reg.register(bob.clone(), "bob-1".into(), api_key_metadata())
.expect("api key registration");
let alice_keys = reg.keys_for_did(&alice);
assert_eq!(alice_keys.len(), 2);
assert!(alice_keys.iter().all(|r| r.did == alice));
let bob_keys = reg.keys_for_did(&bob);
assert_eq!(bob_keys.len(), 1);
assert_eq!(bob_keys[0].did, bob);
}
#[test]
fn api_key_plaintext_shown_once() {
let mut reg = ApiKeyRegistry::new();
let did = Did::new("did:exo:alice").unwrap();
let (plaintext, record) = reg
.register(did, "test".into(), api_key_metadata())
.expect("api key registration");
let key_bytes = hex::decode(plaintext.as_str()).unwrap();
let computed_hash = Hash256::digest(&key_bytes);
assert_eq!(computed_hash, record.key_hash);
let resolved = reg.resolve(plaintext.as_str()).unwrap();
assert_eq!(resolved.key_hash, record.key_hash);
}
#[test]
fn api_key_metadata_rejects_zero_created_at() {
let metadata = ApiKeyMetadata::new(Timestamp::ZERO);
assert!(
matches!(metadata, Err(GatewayError::BadRequest(reason)) if reason.contains("created_at"))
);
}
#[test]
fn api_key_registry_register_propagates_entropy_failure_without_mutation() {
let mut reg = ApiKeyRegistry::new();
let did = Did::new("did:exo:alice").unwrap();
let err = reg
.register_with_entropy(did.clone(), "test key".into(), api_key_metadata(), |_| {
Err(GatewayError::Internal("entropy unavailable".into()))
})
.expect_err("entropy failure must propagate");
assert!(matches!(err, GatewayError::Internal(reason) if reason.contains("entropy")));
assert!(
reg.keys_for_did(&did).is_empty(),
"failed key generation must not insert a partial API key record"
);
}
#[test]
fn resolve_credential_uses_trusted_authentication_metadata() {
let mut did_reg = LocalDidRegistry::new();
register_did(&mut did_reg, "did:exo:alice");
let mut api_reg = ApiKeyRegistry::new();
let did = Did::new("did:exo:alice").unwrap();
let key_metadata = ApiKeyMetadata::new(Timestamp::new(1_000, 0)).unwrap();
let (plaintext, record) = api_reg
.register(did, "test key".into(), key_metadata)
.expect("api key registration");
assert_eq!(record.created_at, Timestamp::new(1_000, 0));
let auth_metadata = AuthenticationMetadata::new(Timestamp::new(2_000, 0)).unwrap();
let cred = Credential::ApiKey(plaintext);
let actor = resolve_credential(&cred, &did_reg, &api_reg, auth_metadata).unwrap();
assert_eq!(actor.authenticated_at, Timestamp::new(2_000, 0));
}
#[test]
fn credential_debug_redacts_token_material() {
let api_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let bearer = "bearer-token-that-must-not-appear-in-debug";
let api_debug = format!("{:?}", Credential::ApiKey(api_key.to_owned().into()));
let bearer_debug = format!("{:?}", Credential::BearerToken(bearer.to_owned().into()));
assert!(!api_debug.contains(api_key));
assert!(!bearer_debug.contains(bearer));
assert!(api_debug.contains("<redacted>"));
assert!(bearer_debug.contains("<redacted>"));
}
#[test]
fn credential_secret_material_uses_zeroizing_storage() {
let source = include_str!("auth.rs");
let production = source
.split("#[cfg(test)]")
.next()
.expect("production section");
assert!(production.contains("use zeroize::Zeroizing;"));
assert!(production.contains("ApiKey(Zeroizing<String>)"));
assert!(production.contains("BearerToken(Zeroizing<String>)"));
assert!(production.contains(") -> Result<(Zeroizing<String>, ApiKeyRecord)>"));
assert!(production.contains("let mut key_bytes = Zeroizing::new([0u8; 32]);"));
assert!(production.contains("let key_bytes = Zeroizing::new(hex::decode(api_key).ok()?);"));
}
#[test]
fn api_key_registry_register_does_not_panic_on_entropy_failure() {
let source = include_str!("auth.rs");
let production = source
.split("#[cfg(test)]")
.next()
.expect("production section");
assert!(!production.contains("expect(\"OS entropy source unavailable\")"));
assert!(production.contains(") -> Result<(Zeroizing<String>, ApiKeyRecord)>"));
}
}