use core::marker::PhantomData;
use std::time::{Duration, Instant, SystemTime};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use ed25519_dalek::{Signature as Ed25519Signature, Verifier, VerifyingKey};
use smallvec::SmallVec;
use thiserror::Error;
use crate::authority::capability::CapabilityKind;
use crate::identity::{
KeyId, PublicKey, ServiceIdentity, SessionId, SignatureAlgorithm, TraceId,
};
use crate::proto::Did;
use crate::resolver::{DidResolutionError, DidResolver};
use crate::sealed;
use crate::audit::BatchRejectionReason;
use crate::authority::capability::CapabilitySet;
use crate::authority::predicate::BindError;
use crate::wire::{
accept_to_wire_bytes, decode_accept_wire, decode_established_wire,
decode_hello_wire, decode_reject_wire, decode_wire_envelope,
established_to_wire_bytes, hello_to_wire_bytes, reject_to_wire_bytes,
verify_delegation_receipt, wire_envelope_is_canonical, AttributionChainWire,
AttributionEntryWire, AttributionPrincipal, CapabilityClaim,
DelegationReceiptPayload, HandshakeNonceTracker, JwtNonce, NonceFreshness,
NonceIssuerKey, NoncePrincipal, NonceTracker, NonceTrackerError,
ReceiptVerificationFailure, ResourceScope, SessionNonce, SyncChannelAccept,
SyncChannelEstablished, SyncChannelHello, SyncChannelReject,
SyncRequestedScope, CLAIM_DOMAIN_TAG, MAX_CAPABILITY_CLAIM_SIZE,
MAX_HANDSHAKE_MESSAGE_SIZE,
};
use kryphocron_lexicons::SemVer;
#[derive(Debug, Clone)]
pub struct VerifiedJwt {
issuer: Did,
audience: ServiceIdentity,
issued_at: SystemTime,
expires_at: SystemTime,
scope: JwtScope,
nonce: Option<JwtNonce>,
algorithm: SignatureAlgorithm,
_private: PhantomData<sealed::Token>,
}
impl VerifiedJwt {
#[must_use]
pub(crate) fn new_internal(
issuer: Did,
audience: ServiceIdentity,
issued_at: SystemTime,
expires_at: SystemTime,
scope: JwtScope,
nonce: Option<JwtNonce>,
algorithm: SignatureAlgorithm,
) -> Self {
VerifiedJwt {
issuer,
audience,
issued_at,
expires_at,
scope,
nonce,
algorithm,
_private: PhantomData,
}
}
#[must_use]
pub fn issuer(&self) -> &Did {
&self.issuer
}
#[must_use]
pub fn audience(&self) -> &ServiceIdentity {
&self.audience
}
#[must_use]
pub fn issued_at(&self) -> SystemTime {
self.issued_at
}
#[must_use]
pub fn expires_at(&self) -> SystemTime {
self.expires_at
}
#[must_use]
pub fn scope(&self) -> &JwtScope {
&self.scope
}
#[must_use]
pub fn nonce(&self) -> Option<&JwtNonce> {
self.nonce.as_ref()
}
#[must_use]
pub fn algorithm(&self) -> SignatureAlgorithm {
self.algorithm
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct JwtVerificationConfig {
pub max_clock_skew: Duration,
pub max_validity_window: Duration,
pub require_nonce: bool,
pub accepted_algorithms: &'static [SignatureAlgorithm],
}
impl Default for JwtVerificationConfig {
fn default() -> Self {
JwtVerificationConfig {
max_clock_skew: Duration::from_secs(30),
max_validity_window: Duration::from_secs(3600),
require_nonce: false,
accepted_algorithms: &[SignatureAlgorithm::Ed25519],
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct JwtScope {
pub scopes: SmallVec<[String; 4]>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum JwtVerificationError {
#[error("JWT malformed")]
Malformed,
#[error("JWT algorithm not supported: {0:?}")]
UnsupportedAlgorithm(SignatureAlgorithm),
#[error("JWT signature invalid")]
SignatureInvalid,
#[error("JWT expired (exp={exp:?}, now={now:?})")]
Expired {
exp: SystemTime,
now: SystemTime,
},
#[error("JWT not yet valid")]
NotYetValid {
nbf: SystemTime,
now: SystemTime,
skew: Duration,
},
#[error("JWT wrong audience")]
WrongAudience {
expected: ServiceIdentity,
got: ServiceIdentity,
},
#[error("JWT issuer resolution failed: {0}")]
IssuerResolutionFailed(DidResolutionError),
#[error("issuer key not in DID document")]
IssuerKeyNotInDocument,
#[error("validity window too long")]
ValidityWindowTooLong {
window: Duration,
max: Duration,
},
#[error("nonce missing")]
NonceMissing,
#[error("nonce replay")]
NonceReplay,
}
pub async fn verify_jwt(
raw: &str,
local_audience: &ServiceIdentity,
resolver: &dyn DidResolver,
config: &JwtVerificationConfig,
deadline: Instant,
trace_id: TraceId,
) -> Result<VerifiedJwt, JwtVerificationError> {
let token = parse_authorization_header(raw)?;
let parsed = ParsedJwt::parse(token)?;
if !config.accepted_algorithms.contains(&parsed.algorithm) {
return Err(JwtVerificationError::UnsupportedAlgorithm(parsed.algorithm));
}
let issuer = parsed.payload_iss()?;
let document = resolver
.resolve(&issuer, deadline, trace_id)
.await
.map_err(JwtVerificationError::IssuerResolutionFailed)?;
let public_key = select_signing_key(&document, parsed.kid_hint(), parsed.algorithm)?;
verify_signature(parsed.signing_input(), &parsed.signature, parsed.algorithm, &public_key)?;
let now = SystemTime::now();
parsed.verify_claims_and_construct(local_audience, config, issuer, now)
}
fn parse_authorization_header(raw: &str) -> Result<&str, JwtVerificationError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(JwtVerificationError::Malformed);
}
if let Some(rest) = trimmed
.strip_prefix("Bearer ")
.or_else(|| trimmed.strip_prefix("bearer "))
.or_else(|| trimmed.strip_prefix("BEARER "))
{
let token = rest.trim_start();
if token.is_empty() {
return Err(JwtVerificationError::Malformed);
}
return Ok(token);
}
Ok(trimmed)
}
struct ParsedJwt<'a> {
header: serde_json::Value,
payload: serde_json::Value,
signature: Vec<u8>,
signing_input_str: &'a str,
algorithm: SignatureAlgorithm,
}
impl<'a> ParsedJwt<'a> {
fn parse(token: &'a str) -> Result<Self, JwtVerificationError> {
let mut iter = token.split('.');
let header_b64 = iter.next().ok_or(JwtVerificationError::Malformed)?;
let payload_b64 = iter.next().ok_or(JwtVerificationError::Malformed)?;
let signature_b64 = iter.next().ok_or(JwtVerificationError::Malformed)?;
if iter.next().is_some() {
return Err(JwtVerificationError::Malformed);
}
if header_b64.is_empty() || payload_b64.is_empty() || signature_b64.is_empty() {
return Err(JwtVerificationError::Malformed);
}
let signing_input_len = header_b64.len() + 1 + payload_b64.len();
let signing_input_str = &token[..signing_input_len];
let header_bytes = URL_SAFE_NO_PAD
.decode(header_b64)
.map_err(|_| JwtVerificationError::Malformed)?;
let payload_bytes = URL_SAFE_NO_PAD
.decode(payload_b64)
.map_err(|_| JwtVerificationError::Malformed)?;
let signature = URL_SAFE_NO_PAD
.decode(signature_b64)
.map_err(|_| JwtVerificationError::Malformed)?;
let header: serde_json::Value = serde_json::from_slice(&header_bytes)
.map_err(|_| JwtVerificationError::Malformed)?;
let payload: serde_json::Value = serde_json::from_slice(&payload_bytes)
.map_err(|_| JwtVerificationError::Malformed)?;
let algorithm = parse_alg_header(&header)?;
Ok(ParsedJwt {
header,
payload,
signature,
signing_input_str,
algorithm,
})
}
fn signing_input(&self) -> &[u8] {
self.signing_input_str.as_bytes()
}
fn kid_hint(&self) -> Option<&str> {
self.header.get("kid")?.as_str()
}
fn payload_iss(&self) -> Result<Did, JwtVerificationError> {
let iss = self
.payload
.get("iss")
.and_then(serde_json::Value::as_str)
.ok_or(JwtVerificationError::Malformed)?;
Did::new(iss).map_err(|_| JwtVerificationError::Malformed)
}
fn verify_claims_and_construct(
self,
local_audience: &ServiceIdentity,
config: &JwtVerificationConfig,
issuer: Did,
now: SystemTime,
) -> Result<VerifiedJwt, JwtVerificationError> {
let p = &self.payload;
let aud_str = p
.get("aud")
.and_then(serde_json::Value::as_str)
.ok_or(JwtVerificationError::Malformed)?;
let aud_did = Did::new(aud_str).map_err(|_| JwtVerificationError::Malformed)?;
if aud_did != *local_audience.service_did() {
let got_placeholder = ServiceIdentity::new_internal(
aud_did,
KeyId::from_bytes([0u8; 32]),
PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: [0u8; 32],
},
None,
);
return Err(JwtVerificationError::WrongAudience {
expected: local_audience.clone(),
got: got_placeholder,
});
}
let iat_secs = p
.get("iat")
.and_then(serde_json::Value::as_u64)
.ok_or(JwtVerificationError::Malformed)?;
let iat = SystemTime::UNIX_EPOCH + Duration::from_secs(iat_secs);
let exp_secs = p
.get("exp")
.and_then(serde_json::Value::as_u64)
.ok_or(JwtVerificationError::Malformed)?;
let exp = SystemTime::UNIX_EPOCH + Duration::from_secs(exp_secs);
let nbf = p
.get("nbf")
.and_then(serde_json::Value::as_u64)
.map(|s| SystemTime::UNIX_EPOCH + Duration::from_secs(s));
let window = exp.duration_since(iat).unwrap_or(Duration::ZERO);
if window > config.max_validity_window {
return Err(JwtVerificationError::ValidityWindowTooLong {
window,
max: config.max_validity_window,
});
}
if now > exp + config.max_clock_skew {
return Err(JwtVerificationError::Expired { exp, now });
}
if let Some(nbf_t) = nbf {
if now + config.max_clock_skew < nbf_t {
return Err(JwtVerificationError::NotYetValid {
nbf: nbf_t,
now,
skew: config.max_clock_skew,
});
}
}
if now + config.max_clock_skew < iat {
return Err(JwtVerificationError::NotYetValid {
nbf: iat,
now,
skew: config.max_clock_skew,
});
}
let scope = parse_scope_field(p);
let nonce = parse_nonce_field(p, config.require_nonce)?;
Ok(VerifiedJwt::new_internal(
issuer,
local_audience.clone(),
iat,
exp,
scope,
nonce,
self.algorithm,
))
}
}
fn parse_alg_header(
header: &serde_json::Value,
) -> Result<SignatureAlgorithm, JwtVerificationError> {
let alg = header
.get("alg")
.and_then(serde_json::Value::as_str)
.ok_or(JwtVerificationError::Malformed)?;
if alg.eq_ignore_ascii_case("none") {
return Err(JwtVerificationError::Malformed);
}
match alg {
"EdDSA" => Ok(SignatureAlgorithm::Ed25519),
"ES256" => Ok(SignatureAlgorithm::Es256),
"ES256K" => Ok(SignatureAlgorithm::Es256K),
_ => Err(JwtVerificationError::Malformed),
}
}
fn select_signing_key(
document: &crate::resolver::DidDocument,
kid_hint: Option<&str>,
algorithm: SignatureAlgorithm,
) -> Result<PublicKey, JwtVerificationError> {
let methods = &document.verification_methods;
if methods.is_empty() {
return Err(JwtVerificationError::IssuerKeyNotInDocument);
}
if let Some(hint) = kid_hint {
for (kid, key) in methods {
let kid_hex = kid_to_hex(kid);
if kid_matches(hint, &kid_hex) {
if key.algorithm == algorithm {
return Ok(*key);
}
return Err(JwtVerificationError::IssuerKeyNotInDocument);
}
}
return Err(JwtVerificationError::IssuerKeyNotInDocument);
}
for (_kid, key) in methods {
if key.algorithm == algorithm {
return Ok(*key);
}
}
Err(JwtVerificationError::IssuerKeyNotInDocument)
}
fn kid_matches(hint: &str, hex_id: &str) -> bool {
if hint == hex_id {
return true;
}
if let Some((_, fragment)) = hint.rsplit_once('#') {
return fragment == hex_id;
}
false
}
fn kid_to_hex(kid: &KeyId) -> String {
let mut s = String::with_capacity(64);
for b in kid.as_bytes() {
s.push_str(&format!("{b:02x}"));
}
s
}
fn verify_signature(
signing_input: &[u8],
signature: &[u8],
algorithm: SignatureAlgorithm,
public_key: &PublicKey,
) -> Result<(), JwtVerificationError> {
match algorithm {
SignatureAlgorithm::Ed25519 => {
if signature.len() != ed25519_dalek::SIGNATURE_LENGTH {
return Err(JwtVerificationError::SignatureInvalid);
}
let mut sig_bytes = [0u8; ed25519_dalek::SIGNATURE_LENGTH];
sig_bytes.copy_from_slice(signature);
let sig = Ed25519Signature::from_bytes(&sig_bytes);
let key = VerifyingKey::from_bytes(&public_key.bytes)
.map_err(|_| JwtVerificationError::SignatureInvalid)?;
key.verify(signing_input, &sig)
.map_err(|_| JwtVerificationError::SignatureInvalid)
}
SignatureAlgorithm::Es256 | SignatureAlgorithm::Es256K => {
Err(JwtVerificationError::UnsupportedAlgorithm(algorithm))
}
}
}
fn parse_scope_field(payload: &serde_json::Value) -> JwtScope {
let raw = payload.get("scope").or_else(|| payload.get("scp"));
let mut scopes: SmallVec<[String; 4]> = SmallVec::new();
if let Some(value) = raw {
if let Some(s) = value.as_str() {
for token in s.split_ascii_whitespace() {
if !token.is_empty() {
scopes.push(token.to_string());
}
}
} else if let Some(arr) = value.as_array() {
for item in arr {
if let Some(s) = item.as_str() {
if !s.is_empty() {
scopes.push(s.to_string());
}
}
}
}
}
JwtScope { scopes }
}
fn parse_nonce_field(
payload: &serde_json::Value,
require_nonce: bool,
) -> Result<Option<JwtNonce>, JwtVerificationError> {
let nonce_str = payload.get("nonce").and_then(serde_json::Value::as_str);
match (nonce_str, require_nonce) {
(None, true) => Err(JwtVerificationError::NonceMissing),
(None, false) => Ok(None),
(Some(s), _) => {
let bytes = URL_SAFE_NO_PAD
.decode(s)
.map_err(|_| JwtVerificationError::Malformed)?;
if bytes.len() != 16 {
return Err(JwtVerificationError::Malformed);
}
let mut arr = [0u8; 16];
arr.copy_from_slice(&bytes);
Ok(Some(JwtNonce::from_bytes(arr)))
}
}
}
#[derive(Debug, Clone)]
pub struct VerifiedCapabilityClaim {
issuer: ServiceIdentity,
subject: Did,
capabilities: Vec<CapabilityKind>,
resource_scope: ResourceScope,
trace_id: TraceId,
issued_at: SystemTime,
expires_at: SystemTime,
chain: Option<crate::AttributionChain>,
_private: PhantomData<sealed::Token>,
}
impl VerifiedCapabilityClaim {
#[must_use]
pub(crate) fn new_internal(
issuer: ServiceIdentity,
subject: Did,
capabilities: Vec<CapabilityKind>,
resource_scope: ResourceScope,
trace_id: TraceId,
issued_at: SystemTime,
expires_at: SystemTime,
chain: Option<crate::AttributionChain>,
) -> Self {
VerifiedCapabilityClaim {
issuer,
subject,
capabilities,
resource_scope,
trace_id,
issued_at,
expires_at,
chain,
_private: PhantomData,
}
}
#[must_use]
pub fn issuer(&self) -> &ServiceIdentity {
&self.issuer
}
#[must_use]
pub fn subject(&self) -> &Did {
&self.subject
}
#[must_use]
pub fn capabilities(&self) -> &[CapabilityKind] {
&self.capabilities
}
#[must_use]
pub fn resource_scope(&self) -> &ResourceScope {
&self.resource_scope
}
#[must_use]
pub fn trace_id(&self) -> TraceId {
self.trace_id
}
#[must_use]
pub fn issued_at(&self) -> SystemTime {
self.issued_at
}
#[must_use]
pub fn expires_at(&self) -> SystemTime {
self.expires_at
}
#[must_use]
pub fn chain(&self) -> Option<&crate::AttributionChain> {
self.chain.as_ref()
}
}
#[derive(Debug, Clone)]
pub struct VerifiedSyncMessage {
session_identity: ServiceIdentity,
session_id: SessionId,
payload: VerifiedCapabilityClaim,
_private: PhantomData<sealed::Token>,
}
impl VerifiedSyncMessage {
#[must_use]
pub fn session_identity(&self) -> &ServiceIdentity {
&self.session_identity
}
#[must_use]
pub fn session_id(&self) -> SessionId {
self.session_id
}
#[must_use]
pub fn payload(&self) -> &VerifiedCapabilityClaim {
&self.payload
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ClaimVerificationConfig {
pub max_clock_skew: Duration,
pub max_validity_window: Duration,
pub accepted_algorithms: &'static [SignatureAlgorithm],
}
impl Default for ClaimVerificationConfig {
fn default() -> Self {
ClaimVerificationConfig {
max_clock_skew: Duration::from_secs(30),
max_validity_window: Duration::from_secs(600),
accepted_algorithms: &[SignatureAlgorithm::Ed25519],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum ClaimVerificationError {
#[error("claim malformed")]
Malformed,
#[error("claim algorithm not supported: {0:?}")]
UnsupportedAlgorithm(SignatureAlgorithm),
#[error("claim signature invalid")]
SignatureInvalid,
#[error("claim expired (exp={exp:?}, now={now:?})")]
Expired {
exp: SystemTime,
now: SystemTime,
},
#[error("claim not yet valid")]
NotYetValid {
iat: SystemTime,
now: SystemTime,
skew: Duration,
},
#[error("claim wrong audience")]
WrongAudience {
expected: ServiceIdentity,
got: Did,
},
#[error("claim issuer resolution failed: {0}")]
IssuerResolutionFailed(DidResolutionError),
#[error("issuer key not in DID document")]
IssuerKeyNotInDocument,
#[error("validity window too long")]
ValidityWindowTooLong {
window: Duration,
max: Duration,
},
#[error("claim size {size} exceeds max {max}")]
ClaimTooLarge {
size: usize,
max: usize,
},
#[error("claim nonce replay")]
NonceReplay,
#[error("nonce tracker failure: {0}")]
NonceTrackerFailed(NonceTrackerError),
#[error("non-wire-eligible capability {0:?} on the wire")]
NonWireEligibleCapability(CapabilityKind),
#[error("scope variant not permitted for class")]
NonexhaustiveScopeForClass,
#[error("attribution chain invalid")]
AttributionChainInvalid(BindError),
#[error("claim capabilities exceed last chain hop's granted set")]
ClaimExceedsChainTail,
}
pub async fn verify_capability_claim(
raw_header: &str,
local_audience: &ServiceIdentity,
resolver: &dyn DidResolver,
nonce_tracker: &dyn NonceTracker,
config: &ClaimVerificationConfig,
deadline: Instant,
trace_id: TraceId,
origin_authorized_capabilities: &CapabilitySet,
) -> Result<VerifiedCapabilityClaim, ClaimVerificationError> {
let token = parse_claim_header(raw_header)?;
let wire_bytes = URL_SAFE_NO_PAD
.decode(token)
.map_err(|_| ClaimVerificationError::Malformed)?;
if wire_bytes.len() > MAX_CAPABILITY_CLAIM_SIZE {
return Err(ClaimVerificationError::ClaimTooLarge {
size: wire_bytes.len(),
max: MAX_CAPABILITY_CLAIM_SIZE,
});
}
if !wire_envelope_is_canonical(&wire_bytes) {
return Err(ClaimVerificationError::Malformed);
}
let (
issuer,
audience,
subject,
origin,
capabilities,
resource_scope,
nonce,
trace_id_field,
issued_at,
expires_at,
signature,
) = decode_wire_envelope(&wire_bytes).map_err(|()| ClaimVerificationError::Malformed)?;
if !config.accepted_algorithms.contains(&signature.algorithm) {
return Err(ClaimVerificationError::UnsupportedAlgorithm(signature.algorithm));
}
if audience.service_did() != local_audience.service_did() {
return Err(ClaimVerificationError::WrongAudience {
expected: local_audience.clone(),
got: audience.service_did().clone(),
});
}
for cap in &capabilities {
if !cap.is_wire_eligible() {
return Err(ClaimVerificationError::NonWireEligibleCapability(*cap));
}
if !class_permits_scope(cap.class(), &resource_scope) {
return Err(ClaimVerificationError::NonexhaustiveScopeForClass);
}
}
let window = expires_at
.duration_since(issued_at)
.unwrap_or(Duration::ZERO);
if window > config.max_validity_window {
return Err(ClaimVerificationError::ValidityWindowTooLong {
window,
max: config.max_validity_window,
});
}
let now = SystemTime::now();
if now > expires_at + config.max_clock_skew {
return Err(ClaimVerificationError::Expired {
exp: expires_at,
now,
});
}
if now + config.max_clock_skew < issued_at {
return Err(ClaimVerificationError::NotYetValid {
iat: issued_at,
now,
skew: config.max_clock_skew,
});
}
let document = resolver
.resolve(issuer.service_did(), deadline, trace_id)
.await
.map_err(ClaimVerificationError::IssuerResolutionFailed)?;
let public_key = select_signing_key_for_claim(
&document,
issuer.key_id(),
signature.algorithm,
)?;
let received_claim = CapabilityClaim::new_internal_received(
issuer.clone(),
audience,
subject.clone(),
origin,
capabilities.clone(),
resource_scope.clone(),
nonce,
trace_id_field,
issued_at,
expires_at,
signature,
);
let canonical_payload = received_claim.canonical_payload_bytes();
let mut signing_input =
Vec::with_capacity(CLAIM_DOMAIN_TAG.len() + canonical_payload.len());
signing_input.extend_from_slice(CLAIM_DOMAIN_TAG);
signing_input.extend_from_slice(&canonical_payload);
verify_claim_signature(
&signing_input,
&received_claim.signature().bytes,
signature.algorithm,
&public_key,
)?;
let issuer_partition = NonceIssuerKey {
principal: NoncePrincipal::Service(issuer.service_did().clone()),
key_id: issuer.key_id(),
};
let nonce_bytes = *received_claim.nonce().as_bytes();
match nonce_tracker
.record(
crate::wire::NonceKind::CapabilityClaim,
&issuer_partition,
&nonce_bytes,
now,
)
.map_err(ClaimVerificationError::NonceTrackerFailed)?
{
NonceFreshness::Fresh => {}
NonceFreshness::Replay { .. } => return Err(ClaimVerificationError::NonceReplay),
}
let verified_chain = match received_claim.origin() {
crate::wire::ClaimOrigin::SelfOriginated => None,
crate::wire::ClaimOrigin::DelegatedFromUpstream { chain } => {
let chain_clone = chain.clone();
let verified = verify_attribution_chain(
&chain_clone,
origin_authorized_capabilities,
resolver,
deadline,
trace_id,
)
.await
.map_err(ClaimVerificationError::AttributionChainInvalid)?;
let last_granted = chain_clone
.entries
.last()
.map(|e| e.granted_capabilities.clone())
.unwrap_or_default();
let claim_caps_set =
CapabilitySet::from_kinds(received_claim.capabilities().iter().copied());
if !last_granted.is_superset_of(&claim_caps_set) {
return Err(ClaimVerificationError::ClaimExceedsChainTail);
}
Some(verified)
}
};
Ok(VerifiedCapabilityClaim::new_internal(
issuer,
subject,
capabilities,
resource_scope,
trace_id_field,
issued_at,
expires_at,
verified_chain,
))
}
fn parse_claim_header(raw: &str) -> Result<&str, ClaimVerificationError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(ClaimVerificationError::Malformed);
}
if let Some(rest) = trimmed
.strip_prefix("KryphocronClaim ")
.or_else(|| trimmed.strip_prefix("kryphocronclaim "))
.or_else(|| trimmed.strip_prefix("KRYPHOCRONCLAIM "))
{
let token = rest.trim_start();
if token.is_empty() {
return Err(ClaimVerificationError::Malformed);
}
return Ok(token);
}
Ok(trimmed)
}
fn class_permits_scope(
class: crate::authority::CapabilityClass,
scope: &ResourceScope,
) -> bool {
use crate::authority::CapabilityClass;
match class {
CapabilityClass::User => matches!(scope, ResourceScope::Resource(_)),
CapabilityClass::Channel => true,
CapabilityClass::Substrate | CapabilityClass::Moderation => false,
}
}
fn select_signing_key_for_claim(
document: &crate::resolver::DidDocument,
expected_key_id: KeyId,
algorithm: SignatureAlgorithm,
) -> Result<PublicKey, ClaimVerificationError> {
for (kid, key) in document
.verification_methods
.iter()
.chain(document.rotation_history.iter())
{
if *kid == expected_key_id && key.algorithm == algorithm {
return Ok(*key);
}
}
Err(ClaimVerificationError::IssuerKeyNotInDocument)
}
fn verify_claim_signature(
signing_input: &[u8],
signature: &[u8],
algorithm: SignatureAlgorithm,
public_key: &PublicKey,
) -> Result<(), ClaimVerificationError> {
match algorithm {
SignatureAlgorithm::Ed25519 => {
if signature.len() != ed25519_dalek::SIGNATURE_LENGTH {
return Err(ClaimVerificationError::SignatureInvalid);
}
let mut sig_bytes = [0u8; ed25519_dalek::SIGNATURE_LENGTH];
sig_bytes.copy_from_slice(signature);
let sig = Ed25519Signature::from_bytes(&sig_bytes);
let key = VerifyingKey::from_bytes(&public_key.bytes)
.map_err(|_| ClaimVerificationError::SignatureInvalid)?;
key.verify(signing_input, &sig)
.map_err(|_| ClaimVerificationError::SignatureInvalid)
}
SignatureAlgorithm::Es256 | SignatureAlgorithm::Es256K => {
Err(ClaimVerificationError::UnsupportedAlgorithm(algorithm))
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SyncHandshakeVerificationConfig {
pub max_clock_skew: Duration,
pub local_lexicon_set_version: SemVer,
pub accepted_algorithms: &'static [SignatureAlgorithm],
}
impl Default for SyncHandshakeVerificationConfig {
fn default() -> Self {
SyncHandshakeVerificationConfig {
max_clock_skew: Duration::from_secs(30),
local_lexicon_set_version: SemVer::new(1, 0, 0),
accepted_algorithms: &[SignatureAlgorithm::Ed25519],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum SyncHandshakeVerificationError {
#[error("handshake message malformed")]
Malformed,
#[error("handshake message too large")]
TooLarge,
#[error("handshake signature invalid")]
SignatureInvalid,
#[error("handshake counterparty DID resolution failed: {0}")]
CounterpartyResolutionFailed(DidResolutionError),
#[error("counterparty key id not in DID document")]
CounterpartyKeyNotInDocument,
#[error("handshake algorithm not supported: {0:?}")]
UnsupportedAlgorithm(SignatureAlgorithm),
#[error("initiator lexicon-set major version mismatch")]
LexiconSetMajorVersionMismatch {
local: SemVer,
peer: SemVer,
},
#[error("handshake nonce replay")]
HandshakeNonceReplay {
first_seen_at: SystemTime,
},
#[error("handshake nonce tracker backend unavailable: {0}")]
NonceTrackerBackend(NonceTrackerError),
#[error("handshake `at` is in the future beyond skew tolerance")]
NotYetValid,
#[error("handshake `at` is too old (clock skew exceeded)")]
TooOld,
#[error("counterparty identity mismatch")]
CounterpartyIdentityMismatch,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum SyncMessageVerificationError {
#[error("inner claim verification failed: {0}")]
Claim(#[from] ClaimVerificationError),
#[error("session-bound peer identity mismatch")]
PeerIdentityMismatch,
}
#[derive(Debug, Clone)]
pub struct VerifiedSyncHello {
initiator_identity: ServiceIdentity,
initiator_lexicon_set_version: SemVer,
proposed_session_nonce: SessionNonce,
requested_scope: SyncRequestedScope,
at: SystemTime,
_private: PhantomData<sealed::Token>,
}
impl VerifiedSyncHello {
pub(crate) fn new_internal(
initiator_identity: ServiceIdentity,
initiator_lexicon_set_version: SemVer,
proposed_session_nonce: SessionNonce,
requested_scope: SyncRequestedScope,
at: SystemTime,
) -> Self {
VerifiedSyncHello {
initiator_identity,
initiator_lexicon_set_version,
proposed_session_nonce,
requested_scope,
at,
_private: PhantomData,
}
}
#[must_use]
pub fn initiator_identity(&self) -> &ServiceIdentity {
&self.initiator_identity
}
#[must_use]
pub fn initiator_lexicon_set_version(&self) -> SemVer {
self.initiator_lexicon_set_version
}
#[must_use]
pub fn proposed_session_nonce(&self) -> &SessionNonce {
&self.proposed_session_nonce
}
#[must_use]
pub fn requested_scope(&self) -> &SyncRequestedScope {
&self.requested_scope
}
#[must_use]
pub fn narrowed_scope(
&self,
peer_kind: crate::resolver::PeerKind,
_constraints: Option<&crate::audit::PeerTrustConstraints>,
now: SystemTime,
) -> SyncRequestedScope {
use crate::resolver::PeerKind;
use crate::wire::{SyncTimeWindow, DEFAULT_FEDERATION_TIME_WINDOW};
match peer_kind {
PeerKind::Internal => self.requested_scope.clone(),
PeerKind::Federation => {
if self.requested_scope.time_window.is_some() {
self.requested_scope.clone()
} else {
let mut narrowed = self.requested_scope.clone();
let start = now
.checked_sub(DEFAULT_FEDERATION_TIME_WINDOW)
.unwrap_or(SystemTime::UNIX_EPOCH);
narrowed.time_window = Some(SyncTimeWindow {
start,
end: now,
});
narrowed
}
}
}
}
#[must_use]
pub fn at(&self) -> SystemTime {
self.at
}
}
#[derive(Debug, Clone)]
pub struct VerifiedSyncAccept {
responder_identity: ServiceIdentity,
responder_lexicon_set_version: SemVer,
session_id: SessionId,
negotiated_scope: SyncRequestedScope,
at: SystemTime,
_private: PhantomData<sealed::Token>,
}
impl VerifiedSyncAccept {
pub(crate) fn new_internal(
responder_identity: ServiceIdentity,
responder_lexicon_set_version: SemVer,
session_id: SessionId,
negotiated_scope: SyncRequestedScope,
at: SystemTime,
) -> Self {
VerifiedSyncAccept {
responder_identity,
responder_lexicon_set_version,
session_id,
negotiated_scope,
at,
_private: PhantomData,
}
}
#[must_use]
pub fn responder_identity(&self) -> &ServiceIdentity {
&self.responder_identity
}
#[must_use]
pub fn responder_lexicon_set_version(&self) -> SemVer {
self.responder_lexicon_set_version
}
#[must_use]
pub fn session_id(&self) -> SessionId {
self.session_id
}
#[must_use]
pub fn negotiated_scope(&self) -> &SyncRequestedScope {
&self.negotiated_scope
}
#[must_use]
pub fn at(&self) -> SystemTime {
self.at
}
}
#[derive(Debug, Clone)]
pub struct VerifiedSyncReject {
reason: BatchRejectionReason,
responder_identity: ServiceIdentity,
at: SystemTime,
_private: PhantomData<sealed::Token>,
}
impl VerifiedSyncReject {
pub(crate) fn new_internal(
reason: BatchRejectionReason,
responder_identity: ServiceIdentity,
at: SystemTime,
) -> Self {
VerifiedSyncReject {
reason,
responder_identity,
at,
_private: PhantomData,
}
}
#[must_use]
pub fn reason(&self) -> &BatchRejectionReason {
&self.reason
}
#[must_use]
pub fn responder_identity(&self) -> &ServiceIdentity {
&self.responder_identity
}
#[must_use]
pub fn at(&self) -> SystemTime {
self.at
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum VerifiedSyncResponse {
Accept(VerifiedSyncAccept),
Reject(VerifiedSyncReject),
}
#[derive(Debug, Clone)]
pub struct VerifiedSyncEstablished {
session_id: SessionId,
at: SystemTime,
_private: PhantomData<sealed::Token>,
}
impl VerifiedSyncEstablished {
pub(crate) fn new_internal(session_id: SessionId, at: SystemTime) -> Self {
VerifiedSyncEstablished {
session_id,
at,
_private: PhantomData,
}
}
#[must_use]
pub fn session_id(&self) -> SessionId {
self.session_id
}
#[must_use]
pub fn at(&self) -> SystemTime {
self.at
}
}
impl VerifiedSyncMessage {
pub(crate) fn new_internal(
session_identity: ServiceIdentity,
session_id: SessionId,
payload: VerifiedCapabilityClaim,
) -> Self {
VerifiedSyncMessage {
session_identity,
session_id,
payload,
_private: PhantomData,
}
}
}
pub async fn verify_sync_hello(
wire_bytes: &[u8],
nonce_tracker: &dyn HandshakeNonceTracker,
resolver: &dyn DidResolver,
config: &SyncHandshakeVerificationConfig,
deadline: Instant,
trace_id: TraceId,
) -> Result<VerifiedSyncHello, SyncHandshakeVerificationError> {
if wire_bytes.len() > MAX_HANDSHAKE_MESSAGE_SIZE {
return Err(SyncHandshakeVerificationError::TooLarge);
}
if !handshake_wire_is_canonical_hello(wire_bytes) {
return Err(SyncHandshakeVerificationError::Malformed);
}
let (initiator_identity, initiator_ver, nonce, scope, at, signature) =
decode_hello_wire(wire_bytes)
.map_err(|()| SyncHandshakeVerificationError::Malformed)?;
if !config.accepted_algorithms.contains(&signature.algorithm) {
return Err(SyncHandshakeVerificationError::UnsupportedAlgorithm(
signature.algorithm,
));
}
let now = SystemTime::now();
check_at_window(at, now, config.max_clock_skew)?;
if initiator_ver.major > config.local_lexicon_set_version.major {
return Err(
SyncHandshakeVerificationError::LexiconSetMajorVersionMismatch {
local: config.local_lexicon_set_version,
peer: initiator_ver,
},
);
}
let document = resolver
.resolve(initiator_identity.service_did(), deadline, trace_id)
.await
.map_err(SyncHandshakeVerificationError::CounterpartyResolutionFailed)?;
let public_key = select_handshake_signing_key(
&document,
initiator_identity.key_id(),
signature.algorithm,
)?;
let sign_input = crate::wire::hello_sign_input(
&initiator_identity,
initiator_ver,
&nonce,
&scope,
at,
);
if !crate::wire::verify_handshake_signature(&public_key, &sign_input, &signature) {
return Err(SyncHandshakeVerificationError::SignatureInvalid);
}
match nonce_tracker.check_and_record(&initiator_identity, &nonce, now) {
Ok(NonceFreshness::Fresh) => {}
Ok(NonceFreshness::Replay { first_seen_at }) => {
return Err(SyncHandshakeVerificationError::HandshakeNonceReplay {
first_seen_at,
});
}
Err(e) => {
return Err(SyncHandshakeVerificationError::NonceTrackerBackend(e));
}
}
Ok(VerifiedSyncHello::new_internal(
initiator_identity,
initiator_ver,
nonce,
scope,
at,
))
}
pub async fn verify_sync_response(
wire_bytes: &[u8],
expected_responder_did: &Did,
resolver: &dyn DidResolver,
config: &SyncHandshakeVerificationConfig,
deadline: Instant,
trace_id: TraceId,
) -> Result<VerifiedSyncResponse, SyncHandshakeVerificationError> {
if wire_bytes.is_empty() {
return Err(SyncHandshakeVerificationError::Malformed);
}
let (discriminator, body) = wire_bytes.split_first().expect("non-empty");
match discriminator {
0x00 => verify_sync_accept(body, expected_responder_did, resolver, config, deadline, trace_id)
.await
.map(VerifiedSyncResponse::Accept),
0x01 => verify_sync_reject(body, expected_responder_did, resolver, config, deadline, trace_id)
.await
.map(VerifiedSyncResponse::Reject),
_ => Err(SyncHandshakeVerificationError::Malformed),
}
}
async fn verify_sync_accept(
wire_bytes: &[u8],
expected_responder_did: &Did,
resolver: &dyn DidResolver,
config: &SyncHandshakeVerificationConfig,
deadline: Instant,
trace_id: TraceId,
) -> Result<VerifiedSyncAccept, SyncHandshakeVerificationError> {
if wire_bytes.len() > MAX_HANDSHAKE_MESSAGE_SIZE {
return Err(SyncHandshakeVerificationError::TooLarge);
}
if !handshake_wire_is_canonical_accept(wire_bytes) {
return Err(SyncHandshakeVerificationError::Malformed);
}
let (responder_identity, responder_ver, session_id, negotiated_scope, at, signature) =
decode_accept_wire(wire_bytes)
.map_err(|()| SyncHandshakeVerificationError::Malformed)?;
if responder_identity.service_did() != expected_responder_did {
return Err(SyncHandshakeVerificationError::CounterpartyIdentityMismatch);
}
if !config.accepted_algorithms.contains(&signature.algorithm) {
return Err(SyncHandshakeVerificationError::UnsupportedAlgorithm(
signature.algorithm,
));
}
check_at_window(at, SystemTime::now(), config.max_clock_skew)?;
let document = resolver
.resolve(expected_responder_did, deadline, trace_id)
.await
.map_err(SyncHandshakeVerificationError::CounterpartyResolutionFailed)?;
let public_key = select_handshake_signing_key(
&document,
responder_identity.key_id(),
signature.algorithm,
)?;
let sign_input = crate::wire::accept_sign_input(
&responder_identity,
responder_ver,
&session_id,
&negotiated_scope,
at,
);
if !crate::wire::verify_handshake_signature(&public_key, &sign_input, &signature) {
return Err(SyncHandshakeVerificationError::SignatureInvalid);
}
Ok(VerifiedSyncAccept::new_internal(
responder_identity,
responder_ver,
session_id,
negotiated_scope,
at,
))
}
async fn verify_sync_reject(
wire_bytes: &[u8],
expected_responder_did: &Did,
resolver: &dyn DidResolver,
config: &SyncHandshakeVerificationConfig,
deadline: Instant,
trace_id: TraceId,
) -> Result<VerifiedSyncReject, SyncHandshakeVerificationError> {
if wire_bytes.len() > MAX_HANDSHAKE_MESSAGE_SIZE {
return Err(SyncHandshakeVerificationError::TooLarge);
}
if !handshake_wire_is_canonical_reject(wire_bytes) {
return Err(SyncHandshakeVerificationError::Malformed);
}
let (reason, responder_identity, at, signature) = decode_reject_wire(wire_bytes)
.map_err(|()| SyncHandshakeVerificationError::Malformed)?;
if responder_identity.service_did() != expected_responder_did {
return Err(SyncHandshakeVerificationError::CounterpartyIdentityMismatch);
}
if !config.accepted_algorithms.contains(&signature.algorithm) {
return Err(SyncHandshakeVerificationError::UnsupportedAlgorithm(
signature.algorithm,
));
}
check_at_window(at, SystemTime::now(), config.max_clock_skew)?;
let document = resolver
.resolve(expected_responder_did, deadline, trace_id)
.await
.map_err(SyncHandshakeVerificationError::CounterpartyResolutionFailed)?;
let public_key = select_handshake_signing_key(
&document,
responder_identity.key_id(),
signature.algorithm,
)?;
let sign_input = crate::wire::reject_sign_input(&reason, &responder_identity, at);
if !crate::wire::verify_handshake_signature(&public_key, &sign_input, &signature) {
return Err(SyncHandshakeVerificationError::SignatureInvalid);
}
Ok(VerifiedSyncReject::new_internal(reason, responder_identity, at))
}
pub fn verify_sync_established(
wire_bytes: &[u8],
local_identity: &ServiceIdentity,
initiator_public_key: &PublicKey,
config: &SyncHandshakeVerificationConfig,
) -> Result<VerifiedSyncEstablished, SyncHandshakeVerificationError> {
if wire_bytes.len() > MAX_HANDSHAKE_MESSAGE_SIZE {
return Err(SyncHandshakeVerificationError::TooLarge);
}
if !handshake_wire_is_canonical_established(wire_bytes) {
return Err(SyncHandshakeVerificationError::Malformed);
}
let (session_id, at, signature) = decode_established_wire(wire_bytes)
.map_err(|()| SyncHandshakeVerificationError::Malformed)?;
if !config.accepted_algorithms.contains(&signature.algorithm) {
return Err(SyncHandshakeVerificationError::UnsupportedAlgorithm(
signature.algorithm,
));
}
check_at_window(at, SystemTime::now(), config.max_clock_skew)?;
let sign_input = crate::wire::established_sign_input(&session_id, local_identity, at);
if !crate::wire::verify_handshake_signature(initiator_public_key, &sign_input, &signature) {
return Err(SyncHandshakeVerificationError::SignatureInvalid);
}
Ok(VerifiedSyncEstablished::new_internal(session_id, at))
}
pub async fn verify_sync_message(
raw_header: &str,
session_id: SessionId,
session_peer: &ServiceIdentity,
local_audience: &ServiceIdentity,
resolver: &dyn DidResolver,
nonce_tracker: &dyn NonceTracker,
config: &ClaimVerificationConfig,
deadline: Instant,
trace_id: TraceId,
origin_authorized_capabilities: &CapabilitySet,
) -> Result<VerifiedSyncMessage, SyncMessageVerificationError> {
let claim = verify_capability_claim(
raw_header,
local_audience,
resolver,
nonce_tracker,
config,
deadline,
trace_id,
origin_authorized_capabilities,
)
.await?;
if claim.issuer() != session_peer {
return Err(SyncMessageVerificationError::PeerIdentityMismatch);
}
Ok(VerifiedSyncMessage::new_internal(
session_peer.clone(),
session_id,
claim,
))
}
fn check_at_window(
at: SystemTime,
now: SystemTime,
skew: Duration,
) -> Result<(), SyncHandshakeVerificationError> {
if at > now + skew {
return Err(SyncHandshakeVerificationError::NotYetValid);
}
if let Ok(age) = now.duration_since(at) {
if age > skew {
return Err(SyncHandshakeVerificationError::TooOld);
}
}
Ok(())
}
fn select_handshake_signing_key(
document: &crate::resolver::DidDocument,
expected_key_id: KeyId,
algorithm: SignatureAlgorithm,
) -> Result<PublicKey, SyncHandshakeVerificationError> {
for (kid, key) in document
.verification_methods
.iter()
.chain(document.rotation_history.iter())
{
if *kid == expected_key_id && key.algorithm == algorithm {
return Ok(*key);
}
}
Err(SyncHandshakeVerificationError::CounterpartyKeyNotInDocument)
}
fn handshake_wire_is_canonical_hello(bytes: &[u8]) -> bool {
decode_hello_wire(bytes)
.ok()
.map(|d| {
let h = SyncChannelHello {
initiator_identity: d.0,
initiator_lexicon_set_version: d.1,
proposed_session_nonce: d.2,
requested_scope: d.3,
at: d.4,
initiator_signature: d.5,
};
hello_to_wire_bytes(&h) == bytes
})
.unwrap_or(false)
}
fn handshake_wire_is_canonical_accept(bytes: &[u8]) -> bool {
decode_accept_wire(bytes)
.ok()
.map(|d| {
let a = SyncChannelAccept {
responder_identity: d.0,
responder_lexicon_set_version: d.1,
session_id: d.2,
negotiated_scope: d.3,
at: d.4,
responder_signature: d.5,
};
accept_to_wire_bytes(&a) == bytes
})
.unwrap_or(false)
}
fn handshake_wire_is_canonical_reject(bytes: &[u8]) -> bool {
decode_reject_wire(bytes)
.ok()
.map(|d| {
let r = SyncChannelReject {
reason: d.0,
responder_identity: d.1,
at: d.2,
responder_signature: d.3,
};
reject_to_wire_bytes(&r) == bytes
})
.unwrap_or(false)
}
fn handshake_wire_is_canonical_established(bytes: &[u8]) -> bool {
decode_established_wire(bytes)
.ok()
.map(|d| {
let e = SyncChannelEstablished {
session_id: d.0,
at: d.1,
initiator_signature: d.2,
};
established_to_wire_bytes(&e) == bytes
})
.unwrap_or(false)
}
pub async fn verify_attribution_chain(
chain_wire: &AttributionChainWire,
origin_authorized_capabilities: &CapabilitySet,
resolver: &dyn DidResolver,
deadline: Instant,
trace_id: TraceId,
) -> Result<crate::AttributionChain, BindError> {
if chain_wire.entries.len() > crate::ingress::MAX_CHAIN_DEPTH {
return Err(BindError::AttributionReceiptInvalid {
failing_hop: chain_wire.entries.len() as u8,
reason: ReceiptVerificationFailure::Malformed,
});
}
let static_allowlist: &[SignatureAlgorithm] = &[SignatureAlgorithm::Ed25519];
let mut previous_principal: AttributionPrincipalRef<'_> =
AttributionPrincipalRef::FromOrigin(&chain_wire.origin);
let mut previous_authorized = origin_authorized_capabilities.clone();
let mut verified_entries: Vec<crate::AttributionEntry> = Vec::new();
for (hop_index, entry) in chain_wire.entries.iter().enumerate() {
let hop = u8::try_from(hop_index).unwrap_or(u8::MAX);
verify_hop(
entry,
hop,
&previous_principal,
&previous_authorized,
static_allowlist,
resolver,
deadline,
trace_id,
)
.await
.map_err(|reason| BindError::AttributionReceiptInvalid {
failing_hop: hop,
reason,
})?;
let key_id_used = entry.principal.key_id();
verified_entries.push(crate::AttributionEntry {
requester: principal_to_requester(&entry.principal),
derivation_reason: entry.derivation_reason.clone(),
derived_at: entry.derived_at,
key_id_used,
});
previous_principal = AttributionPrincipalRef::FromEntry(&entry.principal);
previous_authorized = entry.granted_capabilities.clone();
}
let mut chain = crate::AttributionChain::empty();
for entry in verified_entries {
chain
.try_push(entry)
.map_err(|_| BindError::AttributionReceiptInvalid {
failing_hop: chain_wire.entries.len() as u8,
reason: ReceiptVerificationFailure::Malformed,
})?;
}
Ok(chain)
}
#[allow(clippy::too_many_arguments)]
async fn verify_hop(
entry: &AttributionEntryWire,
hop: u8,
previous_principal: &AttributionPrincipalRef<'_>,
previous_authorized: &CapabilitySet,
accepted_algorithms: &[SignatureAlgorithm],
resolver: &dyn DidResolver,
deadline: Instant,
trace_id: TraceId,
) -> Result<(), ReceiptVerificationFailure> {
if !accepted_algorithms.contains(&entry.receipt.algorithm) {
return Err(ReceiptVerificationFailure::AlgorithmNotAccepted(
entry.receipt.algorithm,
));
}
let previous_did = previous_principal.did();
let document = resolver
.resolve(previous_did, deadline, trace_id)
.await
.map_err(ReceiptVerificationFailure::PreviousPrincipalUnresolvable)?;
let recipient_did = entry.principal.did().clone();
let recipient_key_id = entry.principal.key_id().unwrap_or(KeyId::from_bytes([0u8; 32]));
let mut tried_any_key = false;
let mut tried_matching_alg = false;
for (candidate_key_id, candidate_key) in document
.verification_methods
.iter()
.chain(document.rotation_history.iter())
{
tried_any_key = true;
if candidate_key.algorithm != entry.receipt.algorithm {
continue;
}
tried_matching_alg = true;
let payload = DelegationReceiptPayload {
previous_principal_did: previous_did.clone(),
previous_key_id: *candidate_key_id,
recipient_principal_did: recipient_did.clone(),
recipient_key_id,
derivation_reason: entry.derivation_reason.clone(),
granted_capabilities: entry.granted_capabilities.clone(),
derived_at: entry.derived_at,
};
if verify_delegation_receipt(&payload, &entry.receipt, candidate_key) {
if !previous_authorized.is_superset_of(&entry.granted_capabilities) {
return Err(ReceiptVerificationFailure::CapabilityExpansion {
hop,
attempted: entry.granted_capabilities.clone(),
available: previous_authorized.clone(),
});
}
return Ok(());
}
}
if !tried_any_key {
return Err(ReceiptVerificationFailure::KeyNotInRotationHistory {
previous_key_id: KeyId::from_bytes([0u8; 32]),
});
}
if !tried_matching_alg {
return Err(ReceiptVerificationFailure::KeyNotInRotationHistory {
previous_key_id: KeyId::from_bytes([0u8; 32]),
});
}
Err(ReceiptVerificationFailure::SignatureInvalid)
}
enum AttributionPrincipalRef<'a> {
FromOrigin(&'a AttributionPrincipal),
FromEntry(&'a AttributionPrincipal),
}
impl<'a> AttributionPrincipalRef<'a> {
fn did(&self) -> &Did {
match self {
AttributionPrincipalRef::FromOrigin(p) | AttributionPrincipalRef::FromEntry(p) => {
p.did()
}
}
}
}
fn principal_to_requester(p: &AttributionPrincipal) -> crate::ingress::Requester {
match p {
AttributionPrincipal::User(did) => crate::ingress::Requester::Did(did.clone()),
AttributionPrincipal::Service(s) => crate::ingress::Requester::Service(s.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use ed25519_dalek::{Signer, SigningKey};
use std::sync::{Arc, Mutex};
use crate::resolver::{DidDocument, DidResolutionError, DidResolver};
fn local_audience() -> ServiceIdentity {
ServiceIdentity::new_internal(
Did::new("did:web:audience.example").unwrap(),
KeyId::from_bytes([0u8; 32]),
PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: [0u8; 32],
},
None,
)
}
fn issuer_did() -> Did {
Did::new("did:plc:issuerexample").unwrap()
}
fn fixed_signing_key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32])
}
fn fixed_verifying_pubkey() -> PublicKey {
let signing = fixed_signing_key();
PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: signing.verifying_key().to_bytes(),
}
}
fn b64u(input: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(input)
}
fn build_jwt(header: &serde_json::Value, payload: &serde_json::Value) -> String {
let header_b64 = b64u(serde_json::to_vec(header).unwrap().as_slice());
let payload_b64 = b64u(serde_json::to_vec(payload).unwrap().as_slice());
let signing_input = format!("{header_b64}.{payload_b64}");
let sig = fixed_signing_key().sign(signing_input.as_bytes());
let sig_b64 = b64u(&sig.to_bytes());
format!("{signing_input}.{sig_b64}")
}
fn standard_payload(now_secs: u64) -> serde_json::Value {
serde_json::json!({
"iss": issuer_did().as_str(),
"aud": local_audience().service_did().as_str(),
"iat": now_secs,
"exp": now_secs + 600,
"scope": "tools.kryphocron.feed.read",
})
}
fn ed25519_header() -> serde_json::Value {
serde_json::json!({ "alg": "EdDSA", "typ": "JWT" })
}
struct MockResolver {
documents: Mutex<std::collections::HashMap<String, Result<DidDocument, DidResolutionError>>>,
}
impl MockResolver {
fn new() -> Self {
MockResolver {
documents: Mutex::new(std::collections::HashMap::new()),
}
}
fn insert(&self, did: &Did, key_id: KeyId, key: PublicKey) {
let doc = DidDocument {
did: did.clone(),
verification_methods: vec![(key_id, key)],
rotation_history: vec![],
services: vec![],
also_known_as: vec![],
resolved_at: SystemTime::now(),
resolver_cache_max_age: Duration::from_secs(3600),
};
self.documents.lock().unwrap().insert(did.as_str().to_string(), Ok(doc));
}
fn insert_err(&self, did: &Did, err: DidResolutionError) {
self.documents.lock().unwrap().insert(did.as_str().to_string(), Err(err));
}
}
#[async_trait]
impl DidResolver for MockResolver {
async fn resolve(
&self,
did: &Did,
_deadline: Instant,
_trace_id: TraceId,
) -> Result<DidDocument, DidResolutionError> {
self.documents
.lock()
.unwrap()
.get(did.as_str())
.cloned()
.unwrap_or(Err(DidResolutionError::NotFound))
}
async fn invalidate(&self, _did: &Did, _trace_id: TraceId) {}
}
fn deadline() -> Instant {
Instant::now() + Duration::from_secs(30)
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn populated_resolver() -> Arc<MockResolver> {
let resolver = Arc::new(MockResolver::new());
resolver.insert(&issuer_did(), KeyId::from_bytes([1u8; 32]), fixed_verifying_pubkey());
resolver
}
#[test]
fn jwt_config_default_is_ed25519_only() {
let c = JwtVerificationConfig::default();
assert_eq!(c.accepted_algorithms, &[SignatureAlgorithm::Ed25519]);
assert!(!c.require_nonce);
}
#[test]
fn jwt_config_defaults_match_7_2_recommendations() {
let c = JwtVerificationConfig::default();
assert_eq!(c.max_clock_skew, Duration::from_secs(30));
assert_eq!(c.max_validity_window, Duration::from_secs(3600));
}
#[test]
fn kid_exact_match() {
assert!(kid_matches("deadbeef", "deadbeef"));
}
#[test]
fn kid_fragment_match() {
assert!(kid_matches("did:plc:abc123#deadbeef", "deadbeef"));
assert!(kid_matches("did:web:example.com#deadbeef", "deadbeef"));
}
#[test]
fn kid_suffix_no_longer_matches() {
assert!(!kid_matches("prefixdeadbeef", "deadbeef"));
assert!(!kid_matches("aabbccdeadbeef", "deadbeef"));
assert!(!kid_matches("did:plc:abc#", "deadbeef"));
}
#[tokio::test]
async fn malformed_jwt_with_wrong_segment_count_returns_malformed() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
for bad in ["", "only_one_segment", "two.segments", "four.segments.are.bad"] {
let err = verify_jwt(bad, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::Malformed), "input {bad:?} -> {err:?}");
}
}
#[tokio::test]
async fn malformed_jwt_with_invalid_base64url_returns_malformed() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let err = verify_jwt("***.***.***", &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::Malformed));
}
#[tokio::test]
async fn malformed_jwt_with_invalid_json_in_header_returns_malformed() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let bad = format!("{}.{}.{}", b64u(b"not json"), b64u(b"{}"), b64u(b"sig"));
let err = verify_jwt(&bad, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::Malformed));
}
#[tokio::test]
async fn alg_none_returns_malformed_not_unsupported() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
for none_variant in ["none", "None", "NONE", "nOnE"] {
let header = serde_json::json!({ "alg": none_variant });
let token = build_jwt(&header, &standard_payload(now_secs()));
let err = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(
matches!(err, JwtVerificationError::Malformed),
"alg={none_variant:?} -> {err:?}"
);
}
}
#[tokio::test]
async fn unknown_alg_string_returns_malformed() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let header = serde_json::json!({ "alg": "MyCustomAlg" });
let token = build_jwt(&header, &standard_payload(now_secs()));
let err = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::Malformed));
}
#[tokio::test]
async fn known_alg_outside_allowlist_returns_unsupported_algorithm() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let header = serde_json::json!({ "alg": "ES256" });
let token = build_jwt(&header, &standard_payload(now_secs()));
let err = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::UnsupportedAlgorithm(SignatureAlgorithm::Es256)));
}
#[tokio::test]
async fn happy_path_verifies_an_ed25519_signed_jwt() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let payload = standard_payload(now_secs());
let token = build_jwt(&ed25519_header(), &payload);
let v = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap();
assert_eq!(v.issuer().as_str(), issuer_did().as_str());
assert_eq!(v.algorithm(), SignatureAlgorithm::Ed25519);
assert_eq!(v.scope().scopes.as_slice(), &["tools.kryphocron.feed.read"]);
assert!(v.nonce().is_none());
}
#[tokio::test]
async fn tampered_payload_fails_signature_invalid() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let payload = standard_payload(now_secs());
let token = build_jwt(&ed25519_header(), &payload);
let mut parts = token.split('.');
let header_b64 = parts.next().unwrap();
let _orig_payload = parts.next().unwrap();
let sig_b64 = parts.next().unwrap();
let other_payload = serde_json::json!({
"iss": issuer_did().as_str(),
"aud": local_audience().service_did().as_str(),
"iat": now_secs(),
"exp": now_secs() + 600,
"scope": "different.scope",
});
let other_payload_b64 = b64u(serde_json::to_vec(&other_payload).unwrap().as_slice());
let tampered = format!("{header_b64}.{other_payload_b64}.{sig_b64}");
let err = verify_jwt(&tampered, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::SignatureInvalid));
}
#[tokio::test]
async fn tampered_signature_fails_signature_invalid() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let token = build_jwt(&ed25519_header(), &standard_payload(now_secs()));
let mut parts = token.rsplitn(2, '.');
parts.next().unwrap(); let prefix = parts.next().unwrap();
let zero_sig = b64u(&[0u8; ed25519_dalek::SIGNATURE_LENGTH]);
let tampered = format!("{prefix}.{zero_sig}");
let err = verify_jwt(&tampered, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::SignatureInvalid));
}
#[tokio::test]
async fn wrong_key_fails_signature_invalid() {
let r = Arc::new(MockResolver::new());
let wrong_key = PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: SigningKey::from_bytes(&[99u8; 32]).verifying_key().to_bytes(),
};
r.insert(&issuer_did(), KeyId::from_bytes([1u8; 32]), wrong_key);
let cfg = JwtVerificationConfig::default();
let token = build_jwt(&ed25519_header(), &standard_payload(now_secs()));
let err = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::SignatureInvalid));
}
#[tokio::test]
async fn expired_jwt_returns_expired_with_exp_and_now() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let now = now_secs();
let payload = serde_json::json!({
"iss": issuer_did().as_str(),
"aud": local_audience().service_did().as_str(),
"iat": now - 7200,
"exp": now - 3600,
});
let token = build_jwt(&ed25519_header(), &payload);
let err = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::Expired { .. }));
}
#[tokio::test]
async fn future_dated_iat_returns_not_yet_valid() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let now = now_secs();
let payload = serde_json::json!({
"iss": issuer_did().as_str(),
"aud": local_audience().service_did().as_str(),
"iat": now + 300,
"exp": now + 600,
});
let token = build_jwt(&ed25519_header(), &payload);
let err = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::NotYetValid { .. }));
}
#[tokio::test]
async fn nbf_in_future_returns_not_yet_valid() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let now = now_secs();
let payload = serde_json::json!({
"iss": issuer_did().as_str(),
"aud": local_audience().service_did().as_str(),
"iat": now,
"exp": now + 600,
"nbf": now + 300,
});
let token = build_jwt(&ed25519_header(), &payload);
let err = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::NotYetValid { .. }));
}
#[tokio::test]
async fn wrong_audience_returns_wrong_audience() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let payload = serde_json::json!({
"iss": issuer_did().as_str(),
"aud": "did:web:somewhere.else",
"iat": now_secs(),
"exp": now_secs() + 600,
});
let token = build_jwt(&ed25519_header(), &payload);
let err = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::WrongAudience { .. }));
}
#[tokio::test]
async fn validity_window_too_long_returns_validity_window_too_long() {
let r = populated_resolver();
let cfg = JwtVerificationConfig {
max_validity_window: Duration::from_secs(60),
..JwtVerificationConfig::default()
};
let now = now_secs();
let payload = serde_json::json!({
"iss": issuer_did().as_str(),
"aud": local_audience().service_did().as_str(),
"iat": now,
"exp": now + 3600,
});
let token = build_jwt(&ed25519_header(), &payload);
let err = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::ValidityWindowTooLong { .. }));
}
#[tokio::test]
async fn issuer_resolution_failure_propagates() {
let r = Arc::new(MockResolver::new());
r.insert_err(&issuer_did(), DidResolutionError::NotFound);
let cfg = JwtVerificationConfig::default();
let token = build_jwt(&ed25519_header(), &standard_payload(now_secs()));
let err = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::IssuerResolutionFailed(_)));
}
#[tokio::test]
async fn issuer_key_not_in_document_when_document_empty() {
let r = Arc::new(MockResolver::new());
let empty_doc = DidDocument {
did: issuer_did(),
verification_methods: vec![],
rotation_history: vec![],
services: vec![],
also_known_as: vec![],
resolved_at: SystemTime::now(),
resolver_cache_max_age: Duration::from_secs(3600),
};
r.documents.lock().unwrap().insert(issuer_did().as_str().to_string(), Ok(empty_doc));
let cfg = JwtVerificationConfig::default();
let token = build_jwt(&ed25519_header(), &standard_payload(now_secs()));
let err = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::IssuerKeyNotInDocument));
}
#[tokio::test]
async fn require_nonce_true_with_missing_nonce_returns_nonce_missing() {
let r = populated_resolver();
let cfg = JwtVerificationConfig {
require_nonce: true,
..JwtVerificationConfig::default()
};
let token = build_jwt(&ed25519_header(), &standard_payload(now_secs()));
let err = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::NonceMissing));
}
#[tokio::test]
async fn require_nonce_false_with_present_nonce_succeeds() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let nonce_bytes = [0xABu8; 16];
let mut payload = standard_payload(now_secs());
payload["nonce"] = serde_json::Value::String(b64u(&nonce_bytes));
let token = build_jwt(&ed25519_header(), &payload);
let v = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap();
assert_eq!(v.nonce().unwrap().as_bytes(), &nonce_bytes);
}
#[test]
fn nonce_replay_variant_is_reachable() {
let _e = JwtVerificationError::NonceReplay;
}
#[tokio::test]
async fn scope_extracted_from_space_delimited_string() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let mut payload = standard_payload(now_secs());
payload["scope"] = serde_json::Value::String("a.b c.d e.f".into());
let token = build_jwt(&ed25519_header(), &payload);
let v = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap();
assert_eq!(v.scope().scopes.as_slice(), &["a.b", "c.d", "e.f"]);
}
#[tokio::test]
async fn scope_extracted_from_json_array() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let mut payload = standard_payload(now_secs());
payload["scope"] = serde_json::json!(["x.y", "z.w"]);
let token = build_jwt(&ed25519_header(), &payload);
let v = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap();
assert_eq!(v.scope().scopes.as_slice(), &["x.y", "z.w"]);
}
#[tokio::test]
async fn scope_extracted_from_scp_field_name() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let mut payload = standard_payload(now_secs());
payload.as_object_mut().unwrap().remove("scope");
payload["scp"] = serde_json::Value::String("only.scope".into());
let token = build_jwt(&ed25519_header(), &payload);
let v = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap();
assert_eq!(v.scope().scopes.as_slice(), &["only.scope"]);
}
#[tokio::test]
async fn missing_scope_yields_empty_jwt_scope() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let mut payload = standard_payload(now_secs());
payload.as_object_mut().unwrap().remove("scope");
let token = build_jwt(&ed25519_header(), &payload);
let v = verify_jwt(&token, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap();
assert!(v.scope().scopes.is_empty());
}
#[tokio::test]
async fn authorization_header_with_bearer_prefix_succeeds() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let raw_token = build_jwt(&ed25519_header(), &standard_payload(now_secs()));
let header_value = format!("Bearer {raw_token}");
let v = verify_jwt(&header_value, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap();
assert_eq!(v.issuer().as_str(), issuer_did().as_str());
}
#[tokio::test]
async fn authorization_header_lowercase_bearer_also_succeeds() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let raw_token = build_jwt(&ed25519_header(), &standard_payload(now_secs()));
let header_value = format!("bearer {raw_token}");
let _v = verify_jwt(&header_value, &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap();
}
#[tokio::test]
async fn empty_authorization_header_returns_malformed() {
let r = populated_resolver();
let cfg = JwtVerificationConfig::default();
let err = verify_jwt("", &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::Malformed));
let err = verify_jwt("Bearer ", &local_audience(), &*r, &cfg, deadline(), TraceId::from_bytes([0u8; 16]))
.await
.unwrap_err();
assert!(matches!(err, JwtVerificationError::Malformed));
}
use crate::wire::{CapabilityClaim, ClaimNonce, DefaultNonceTracker, ResourceScope};
use crate::authority::CapabilityKind;
use crate::authority::subjects::ResourceId;
fn issuer_signing_key() -> ed25519_dalek::SigningKey {
ed25519_dalek::SigningKey::from_bytes(&[7u8; 32])
}
fn issuer_service_identity() -> ServiceIdentity {
let signing = issuer_signing_key();
ServiceIdentity::new_internal(
Did::new("did:plc:claimissuer").unwrap(),
KeyId::from_bytes([0xAA; 32]),
PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: signing.verifying_key().to_bytes(),
},
None,
)
}
fn audience_service_identity() -> ServiceIdentity {
ServiceIdentity::new_internal(
Did::new("did:web:audience.example").unwrap(),
KeyId::from_bytes([0xBB; 32]),
PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: [0u8; 32],
},
None,
)
}
fn sample_resource_id() -> ResourceId {
ResourceId::new(
Did::new("did:plc:owner").unwrap(),
crate::Nsid::new("tools.kryphocron.feed.postPrivate").unwrap(),
crate::Rkey::new("samplerkey").unwrap(),
)
}
fn build_claim() -> CapabilityClaim {
CapabilityClaim::new(
issuer_service_identity(),
audience_service_identity(),
Did::new("did:plc:subject").unwrap(),
vec![CapabilityKind::ViewPrivate],
ResourceScope::Resource(sample_resource_id()),
ClaimNonce::from_bytes([0xCC; 16]),
TraceId::from_bytes([0xDD; 16]),
Duration::from_secs(60),
&issuer_signing_key(),
)
.unwrap()
}
fn populated_claim_resolver() -> Arc<MockResolver> {
let resolver = Arc::new(MockResolver::new());
let issuer = issuer_service_identity();
resolver.insert(
issuer.service_did(),
issuer.key_id(),
*issuer.key_material(),
);
resolver
}
fn b64u_wire(claim: &CapabilityClaim) -> String {
URL_SAFE_NO_PAD.encode(claim.to_wire_bytes())
}
#[test]
fn claim_config_default_is_ed25519_only() {
let c = ClaimVerificationConfig::default();
assert_eq!(c.accepted_algorithms, &[SignatureAlgorithm::Ed25519]);
assert_eq!(c.max_clock_skew, Duration::from_secs(30));
assert_eq!(c.max_validity_window, Duration::from_secs(600));
}
#[tokio::test]
async fn claim_happy_path_verifies_an_ed25519_signed_claim() {
let claim = build_claim();
let resolver = populated_claim_resolver();
let tracker = DefaultNonceTracker::new();
let cfg = ClaimVerificationConfig::default();
let header = format!("KryphocronClaim {}", b64u_wire(&claim));
let v = verify_capability_claim(
&header,
&audience_service_identity(),
&*resolver,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap();
assert_eq!(v.issuer().service_did().as_str(), "did:plc:claimissuer");
assert_eq!(v.subject().as_str(), "did:plc:subject");
assert_eq!(v.capabilities(), &[CapabilityKind::ViewPrivate]);
}
#[tokio::test]
async fn claim_replay_against_same_nonce_returns_nonce_replay() {
let claim = build_claim();
let resolver = populated_claim_resolver();
let tracker = DefaultNonceTracker::new();
let cfg = ClaimVerificationConfig::default();
let header = format!("KryphocronClaim {}", b64u_wire(&claim));
let _v = verify_capability_claim(
&header,
&audience_service_identity(),
&*resolver,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap();
let err = verify_capability_claim(
&header,
&audience_service_identity(),
&*resolver,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap_err();
assert!(matches!(err, ClaimVerificationError::NonceReplay));
}
#[tokio::test]
async fn claim_wrong_audience_returns_wrong_audience_with_did() {
let claim = build_claim();
let resolver = populated_claim_resolver();
let tracker = DefaultNonceTracker::new();
let cfg = ClaimVerificationConfig::default();
let header = format!("KryphocronClaim {}", b64u_wire(&claim));
let other_audience = ServiceIdentity::new_internal(
Did::new("did:web:somewhere.else").unwrap(),
KeyId::from_bytes([0; 32]),
PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: [0; 32],
},
None,
);
let err = verify_capability_claim(
&header,
&other_audience,
&*resolver,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap_err();
match err {
ClaimVerificationError::WrongAudience { got, .. } => {
assert_eq!(got.as_str(), "did:web:audience.example");
}
other => panic!("expected WrongAudience, got {other:?}"),
}
}
#[tokio::test]
async fn claim_with_invalid_base64url_returns_malformed() {
let resolver = populated_claim_resolver();
let tracker = DefaultNonceTracker::new();
let cfg = ClaimVerificationConfig::default();
let err = verify_capability_claim(
"KryphocronClaim ***not-base64url***",
&audience_service_identity(),
&*resolver,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap_err();
assert!(matches!(err, ClaimVerificationError::Malformed));
}
#[tokio::test]
async fn claim_with_non_canonical_cbor_returns_malformed() {
let non_canonical: Vec<u8> = vec![
0xA2, 0x65, 0x7A, 0x65, 0x62, 0x72, 0x61, 0x01, 0x65, 0x61, 0x70, 0x70,
0x6C, 0x65, 0x02,
];
let header = format!("KryphocronClaim {}", URL_SAFE_NO_PAD.encode(&non_canonical));
let resolver = populated_claim_resolver();
let tracker = DefaultNonceTracker::new();
let cfg = ClaimVerificationConfig::default();
let err = verify_capability_claim(
&header,
&audience_service_identity(),
&*resolver,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap_err();
assert!(matches!(err, ClaimVerificationError::Malformed));
}
#[tokio::test]
async fn claim_too_large_returns_claim_too_large() {
let oversized = vec![0u8; MAX_CAPABILITY_CLAIM_SIZE + 1];
let header = format!("KryphocronClaim {}", URL_SAFE_NO_PAD.encode(&oversized));
let resolver = populated_claim_resolver();
let tracker = DefaultNonceTracker::new();
let cfg = ClaimVerificationConfig::default();
let err = verify_capability_claim(
&header,
&audience_service_identity(),
&*resolver,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap_err();
assert!(matches!(err, ClaimVerificationError::ClaimTooLarge { .. }));
}
#[tokio::test]
async fn claim_tampered_signature_fails_signature_invalid() {
let claim = build_claim();
let resolver = populated_claim_resolver();
let tracker = DefaultNonceTracker::new();
let cfg = ClaimVerificationConfig::default();
let wire = claim.to_wire_bytes();
let mut tampered = wire.clone();
let head_pos = tampered
.windows(2)
.position(|w| w == [0x58, 0x40])
.expect("wire envelope must contain a bytes(64) for the signature");
let sig_start = head_pos + 2;
for b in &mut tampered[sig_start..sig_start + 64] {
*b = 0;
}
let header = format!("KryphocronClaim {}", URL_SAFE_NO_PAD.encode(&tampered));
let err = verify_capability_claim(
&header,
&audience_service_identity(),
&*resolver,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap_err();
assert!(matches!(err, ClaimVerificationError::SignatureInvalid));
}
#[tokio::test]
async fn claim_signature_without_domain_tag_fails_verification() {
use ed25519_dalek::Signer;
let claim = build_claim();
let canonical_payload = claim.canonical_payload_bytes();
let bare_sig = issuer_signing_key().sign(&canonical_payload);
let forged_signature = crate::wire::ClaimSignature {
algorithm: SignatureAlgorithm::Ed25519,
bytes: bare_sig.to_bytes(),
};
let forged_claim = crate::wire::CapabilityClaim::new_internal_received(
issuer_service_identity(),
audience_service_identity(),
Did::new("did:plc:subject").unwrap(),
crate::wire::ClaimOrigin::SelfOriginated,
vec![CapabilityKind::ViewPrivate],
crate::wire::ResourceScope::Resource(sample_resource_id()),
ClaimNonce::from_bytes([0xCC; 16]),
TraceId::from_bytes([0xDD; 16]),
claim.issued_at(),
claim.expires_at(),
forged_signature,
);
let header = format!("KryphocronClaim {}", URL_SAFE_NO_PAD.encode(forged_claim.to_wire_bytes()));
let resolver = populated_claim_resolver();
let tracker = DefaultNonceTracker::new();
let cfg = ClaimVerificationConfig::default();
let err = verify_capability_claim(
&header,
&audience_service_identity(),
&*resolver,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap_err();
assert!(
matches!(err, ClaimVerificationError::SignatureInvalid),
"domain-separation bypass must fail; got {err:?}"
);
}
#[tokio::test]
async fn claim_with_substrate_capability_returns_non_wire_eligible_at_receive() {
use ed25519_dalek::Signer;
let issuer = issuer_service_identity();
let bad_caps = vec![CapabilityKind::ScanShard]; let issued_at = SystemTime::now();
let expires_at = issued_at + Duration::from_secs(60);
let nonce = ClaimNonce::from_bytes([0xEE; 16]);
let trace = TraceId::from_bytes([0xFF; 16]);
let placeholder_sig = crate::wire::ClaimSignature {
algorithm: SignatureAlgorithm::Ed25519,
bytes: [0; 64],
};
let provisional = crate::wire::CapabilityClaim::new_internal_received(
issuer.clone(),
audience_service_identity(),
Did::new("did:plc:subject").unwrap(),
crate::wire::ClaimOrigin::SelfOriginated,
bad_caps.clone(),
crate::wire::ResourceScope::ClassWideAdministrative,
nonce,
trace,
issued_at,
expires_at,
placeholder_sig,
);
let canonical_payload = provisional.canonical_payload_bytes();
let mut signing_input = Vec::new();
signing_input.extend_from_slice(crate::wire::CLAIM_DOMAIN_TAG);
signing_input.extend_from_slice(&canonical_payload);
let real_sig = issuer_signing_key().sign(&signing_input);
let signed = crate::wire::CapabilityClaim::new_internal_received(
issuer,
audience_service_identity(),
Did::new("did:plc:subject").unwrap(),
crate::wire::ClaimOrigin::SelfOriginated,
bad_caps,
crate::wire::ResourceScope::ClassWideAdministrative,
nonce,
trace,
issued_at,
expires_at,
crate::wire::ClaimSignature {
algorithm: SignatureAlgorithm::Ed25519,
bytes: real_sig.to_bytes(),
},
);
let header = format!(
"KryphocronClaim {}",
URL_SAFE_NO_PAD.encode(signed.to_wire_bytes())
);
let resolver = populated_claim_resolver();
let tracker = DefaultNonceTracker::new();
let cfg = ClaimVerificationConfig::default();
let err = verify_capability_claim(
&header,
&audience_service_identity(),
&*resolver,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap_err();
assert!(matches!(
err,
ClaimVerificationError::NonWireEligibleCapability(CapabilityKind::ScanShard)
));
}
#[tokio::test]
async fn claim_with_known_alg_outside_allowlist_returns_unsupported_algorithm() {
let claim = build_claim();
let resolver = populated_claim_resolver();
let tracker = DefaultNonceTracker::new();
let cfg = ClaimVerificationConfig {
accepted_algorithms: &[],
..ClaimVerificationConfig::default()
};
let header = format!("KryphocronClaim {}", b64u_wire(&claim));
let err = verify_capability_claim(
&header,
&audience_service_identity(),
&*resolver,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap_err();
assert!(matches!(
err,
ClaimVerificationError::UnsupportedAlgorithm(SignatureAlgorithm::Ed25519)
));
}
#[tokio::test]
async fn claim_signed_by_rotated_out_key_still_verifies_via_rotation_history() {
let claim = build_claim();
let issuer = issuer_service_identity();
let r = Arc::new(MockResolver::new());
let rotated_in_key = ed25519_dalek::SigningKey::from_bytes(&[99u8; 32]);
let rotated_in_pub = PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: rotated_in_key.verifying_key().to_bytes(),
};
let mut doc = crate::resolver::DidDocument {
did: issuer.service_did().clone(),
verification_methods: vec![(KeyId::from_bytes([0xFF; 32]), rotated_in_pub)],
rotation_history: vec![(issuer.key_id(), *issuer.key_material())],
services: vec![],
also_known_as: vec![],
resolved_at: SystemTime::now(),
resolver_cache_max_age: Duration::from_secs(3600),
};
doc.rotation_history = vec![(issuer.key_id(), *issuer.key_material())];
r.documents
.lock()
.unwrap()
.insert(issuer.service_did().as_str().to_string(), Ok(doc));
let tracker = DefaultNonceTracker::new();
let cfg = ClaimVerificationConfig::default();
let header = format!("KryphocronClaim {}", b64u_wire(&claim));
let v = verify_capability_claim(
&header,
&audience_service_identity(),
&*r,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap();
assert_eq!(v.issuer().service_did().as_str(), "did:plc:claimissuer");
}
#[tokio::test]
async fn claim_signed_by_unknown_key_returns_issuer_key_not_in_document() {
let claim = build_claim();
let issuer = issuer_service_identity();
let r = Arc::new(MockResolver::new());
let unrelated_key = ed25519_dalek::SigningKey::from_bytes(&[123u8; 32]);
let unrelated_pub = PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: unrelated_key.verifying_key().to_bytes(),
};
let doc = crate::resolver::DidDocument {
did: issuer.service_did().clone(),
verification_methods: vec![(KeyId::from_bytes([0x11; 32]), unrelated_pub)],
rotation_history: vec![(KeyId::from_bytes([0x22; 32]), unrelated_pub)],
services: vec![],
also_known_as: vec![],
resolved_at: SystemTime::now(),
resolver_cache_max_age: Duration::from_secs(3600),
};
r.documents
.lock()
.unwrap()
.insert(issuer.service_did().as_str().to_string(), Ok(doc));
let tracker = DefaultNonceTracker::new();
let cfg = ClaimVerificationConfig::default();
let header = format!("KryphocronClaim {}", b64u_wire(&claim));
let err = verify_capability_claim(
&header,
&audience_service_identity(),
&*r,
&tracker,
&cfg,
deadline(),
TraceId::from_bytes([0u8; 16]),
&CapabilitySet::empty(),
)
.await
.unwrap_err();
assert!(matches!(err, ClaimVerificationError::IssuerKeyNotInDocument));
}
fn handshake_signing_pair(seed: u8) -> (SigningKey, PublicKey) {
let sk = SigningKey::from_bytes(&[seed; 32]);
let vk = sk.verifying_key();
(
sk,
PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: vk.to_bytes(),
},
)
}
fn make_initiator_identity(seed: u8) -> (ServiceIdentity, SigningKey) {
let (sk, pk) = handshake_signing_pair(seed);
let did_str = format!("did:plc:{seed:02x}initiator0000000");
let did = Did::new(&did_str).unwrap();
let key_id = KeyId::from_bytes([seed; 32]);
let id = ServiceIdentity::new_internal(did, key_id, pk, None);
(id, sk)
}
fn handshake_test_resolver(initiator: &ServiceIdentity) -> Arc<MockResolver> {
let r = Arc::new(MockResolver::new());
r.insert(initiator.service_did(), initiator.key_id(), *initiator.key_material());
r
}
#[tokio::test]
async fn verify_sync_hello_happy_path() {
let (initiator, sk) = make_initiator_identity(0x10);
let resolver = handshake_test_resolver(&initiator);
let tracker = crate::wire::DefaultHandshakeNonceTracker::new();
let cfg = SyncHandshakeVerificationConfig::default();
let nonce = SessionNonce::from_bytes([0x42; 32]);
let scope = SyncRequestedScope {
nsids: smallvec::SmallVec::new(),
time_window: None,
direction: crate::wire::SyncDirection::Bidirectional,
};
let at = SystemTime::now();
let sign_input = crate::wire::hello_sign_input(
&initiator,
SemVer::new(1, 0, 0),
&nonce,
&scope,
at,
);
let sig = crate::wire::sign_handshake_payload(&sk, &sign_input);
let hello = SyncChannelHello {
initiator_identity: initiator.clone(),
initiator_lexicon_set_version: SemVer::new(1, 0, 0),
proposed_session_nonce: nonce,
requested_scope: scope.clone(),
initiator_signature: sig,
at,
};
let bytes = hello_to_wire_bytes(&hello);
let v = verify_sync_hello(
&bytes,
&tracker,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0xAB; 16]),
)
.await
.unwrap();
assert_eq!(v.initiator_identity(), &initiator);
assert_eq!(v.proposed_session_nonce(), &nonce);
}
#[tokio::test]
async fn verify_sync_hello_replay_returns_handshake_nonce_replay() {
let (initiator, sk) = make_initiator_identity(0x11);
let resolver = handshake_test_resolver(&initiator);
let tracker = crate::wire::DefaultHandshakeNonceTracker::new();
let cfg = SyncHandshakeVerificationConfig::default();
let nonce = SessionNonce::from_bytes([0x43; 32]);
let scope = SyncRequestedScope {
nsids: smallvec::SmallVec::new(),
time_window: None,
direction: crate::wire::SyncDirection::Receive,
};
let at = SystemTime::now();
let sign_input = crate::wire::hello_sign_input(
&initiator,
SemVer::new(1, 0, 0),
&nonce,
&scope,
at,
);
let sig = crate::wire::sign_handshake_payload(&sk, &sign_input);
let hello = SyncChannelHello {
initiator_identity: initiator.clone(),
initiator_lexicon_set_version: SemVer::new(1, 0, 0),
proposed_session_nonce: nonce,
requested_scope: scope,
initiator_signature: sig,
at,
};
let bytes = hello_to_wire_bytes(&hello);
verify_sync_hello(
&bytes,
&tracker,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0xAB; 16]),
)
.await
.unwrap();
let err = verify_sync_hello(
&bytes,
&tracker,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0xAB; 16]),
)
.await
.unwrap_err();
assert!(
matches!(err, SyncHandshakeVerificationError::HandshakeNonceReplay { .. }),
"expected HandshakeNonceReplay, got {err:?}"
);
}
#[tokio::test]
async fn verify_sync_hello_rejects_major_version_skew() {
let (initiator, sk) = make_initiator_identity(0x12);
let resolver = handshake_test_resolver(&initiator);
let tracker = crate::wire::DefaultHandshakeNonceTracker::new();
let cfg = SyncHandshakeVerificationConfig {
local_lexicon_set_version: SemVer::new(1, 0, 0),
..SyncHandshakeVerificationConfig::default()
};
let initiator_ver = SemVer::new(2, 0, 0);
let nonce = SessionNonce::from_bytes([0x44; 32]);
let scope = SyncRequestedScope {
nsids: smallvec::SmallVec::new(),
time_window: None,
direction: crate::wire::SyncDirection::Send,
};
let at = SystemTime::now();
let sign_input = crate::wire::hello_sign_input(
&initiator, initiator_ver, &nonce, &scope, at,
);
let sig = crate::wire::sign_handshake_payload(&sk, &sign_input);
let hello = SyncChannelHello {
initiator_identity: initiator,
initiator_lexicon_set_version: initiator_ver,
proposed_session_nonce: nonce,
requested_scope: scope,
initiator_signature: sig,
at,
};
let bytes = hello_to_wire_bytes(&hello);
let err = verify_sync_hello(
&bytes,
&tracker,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0xAB; 16]),
)
.await
.unwrap_err();
assert!(
matches!(
err,
SyncHandshakeVerificationError::LexiconSetMajorVersionMismatch { .. }
),
"expected LexiconSetMajorVersionMismatch, got {err:?}"
);
}
#[test]
fn verify_sync_established_fails_against_wrong_responder() {
let (sk, init_pk) = handshake_signing_pair(0x20);
let local_id_a = ServiceIdentity::new_internal(
Did::new("did:plc:respondera000000000000").unwrap(),
KeyId::from_bytes([0x21; 32]),
init_pk,
None,
);
let local_id_b = ServiceIdentity::new_internal(
Did::new("did:plc:responderb000000000000").unwrap(),
KeyId::from_bytes([0x22; 32]),
init_pk,
None,
);
let session_id = SessionId::from_bytes([0xCC; 32]);
let at = SystemTime::now();
let sign_input =
crate::wire::established_sign_input(&session_id, &local_id_a, at);
let sig = crate::wire::sign_handshake_payload(&sk, &sign_input);
let est = SyncChannelEstablished {
session_id,
initiator_signature: sig,
at,
};
let bytes = established_to_wire_bytes(&est);
let cfg = SyncHandshakeVerificationConfig::default();
let err = verify_sync_established(&bytes, &local_id_b, &init_pk, &cfg)
.unwrap_err();
assert!(matches!(err, SyncHandshakeVerificationError::SignatureInvalid));
let v = verify_sync_established(&bytes, &local_id_a, &init_pk, &cfg).unwrap();
assert_eq!(v.session_id(), session_id);
}
fn fresh_verified_sync_hello(time_window: Option<crate::wire::SyncTimeWindow>) -> VerifiedSyncHello {
let scope = SyncRequestedScope {
nsids: smallvec::SmallVec::new(),
time_window,
direction: crate::wire::SyncDirection::Bidirectional,
};
VerifiedSyncHello::new_internal(
ServiceIdentity::new_internal(
Did::new("did:plc:initiator00000000000000").unwrap(),
KeyId::from_bytes([0x10; 32]),
PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: [0x11; 32],
},
None,
),
SemVer::new(1, 0, 0),
SessionNonce::from_bytes([0x42; 32]),
scope,
SystemTime::now(),
)
}
#[test]
fn narrowed_scope_internal_peer_unchanged_even_for_none_window() {
let v = fresh_verified_sync_hello(None);
let now = SystemTime::now();
let narrowed = v.narrowed_scope(crate::resolver::PeerKind::Internal, None, now);
assert!(narrowed.time_window.is_none());
}
#[test]
fn narrowed_scope_federation_peer_with_none_window_narrows_to_7_days() {
let v = fresh_verified_sync_hello(None);
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let narrowed = v.narrowed_scope(crate::resolver::PeerKind::Federation, None, now);
let window = narrowed.time_window.expect("federation None must narrow");
assert_eq!(window.end, now);
assert_eq!(
window.start,
now - crate::wire::DEFAULT_FEDERATION_TIME_WINDOW
);
}
#[test]
fn narrowed_scope_federation_peer_with_bounded_window_unchanged() {
let initiator_window = crate::wire::SyncTimeWindow {
start: SystemTime::UNIX_EPOCH + Duration::from_secs(1_790_000_000),
end: SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000),
};
let v = fresh_verified_sync_hello(Some(initiator_window));
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_001);
let narrowed = v.narrowed_scope(crate::resolver::PeerKind::Federation, None, now);
assert_eq!(narrowed.time_window, Some(initiator_window));
}
#[test]
fn requested_scope_returns_raw_initiator_scope() {
let v = fresh_verified_sync_hello(None);
assert!(v.requested_scope().time_window.is_none());
}
use crate::wire::{sign_delegation_receipt, DelegationReceipt};
fn chain_test_pair(seed: u8) -> (SigningKey, PublicKey, KeyId, Did) {
let sk = SigningKey::from_bytes(&[seed; 32]);
let vk = sk.verifying_key();
let pk = PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: vk.to_bytes(),
};
let key_id = KeyId::from_bytes([seed; 32]);
let did_str = format!("did:plc:{seed:02x}principal000000");
let did = Did::new(&did_str).unwrap();
(sk, pk, key_id, did)
}
fn chain_test_resolver_for(pairs: &[(Did, KeyId, PublicKey)]) -> Arc<MockResolver> {
let r = Arc::new(MockResolver::new());
for (did, key_id, key) in pairs {
r.insert(did, *key_id, *key);
}
r
}
fn build_signed_entry(
previous_did: &Did,
previous_key_id: KeyId,
previous_signing_key: &SigningKey,
recipient_did: &Did,
recipient_key_id: KeyId,
recipient_pk: PublicKey,
granted: CapabilitySet,
) -> AttributionEntryWire {
let derived_at = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let payload = DelegationReceiptPayload {
previous_principal_did: previous_did.clone(),
previous_key_id,
recipient_principal_did: recipient_did.clone(),
recipient_key_id,
derivation_reason: crate::ingress::DerivationReason::DropPrivilegeToAnonymous,
granted_capabilities: granted.clone(),
derived_at,
};
let receipt = sign_delegation_receipt(&payload, previous_signing_key);
AttributionEntryWire {
principal: AttributionPrincipal::Service(
ServiceIdentity::new_internal(
recipient_did.clone(),
recipient_key_id,
recipient_pk,
None,
),
),
derivation_reason: crate::ingress::DerivationReason::DropPrivilegeToAnonymous,
derived_at,
granted_capabilities: granted,
receipt,
}
}
#[tokio::test]
async fn verify_attribution_chain_happy_path_single_hop() {
let (sk_a, pk_a, kid_a, did_a) = chain_test_pair(0xA0);
let (_sk_b, pk_b, kid_b, did_b) = chain_test_pair(0xB0);
let resolver = chain_test_resolver_for(&[(did_a.clone(), kid_a, pk_a)]);
let origin_caps = CapabilitySet::from_kinds(vec![CapabilityKind::ViewPrivate]);
let entry = build_signed_entry(
&did_a, kid_a, &sk_a,
&did_b, kid_b, pk_b,
origin_caps.clone(),
);
let chain = AttributionChainWire {
origin: AttributionPrincipal::Service(ServiceIdentity::new_internal(
did_a.clone(),
kid_a,
pk_a,
None,
)),
entries: smallvec::smallvec![entry],
};
let verified = verify_attribution_chain(
&chain,
&origin_caps,
resolver.as_ref() as &dyn DidResolver,
deadline(),
TraceId::from_bytes([0xCD; 16]),
)
.await
.unwrap();
assert_eq!(verified.entries().len(), 1);
}
#[tokio::test]
async fn verify_attribution_chain_w13_capability_expansion_fails_closed() {
let (sk_a, pk_a, kid_a, did_a) = chain_test_pair(0xA1);
let (_sk_b, pk_b, kid_b, did_b) = chain_test_pair(0xB1);
let resolver = chain_test_resolver_for(&[(did_a.clone(), kid_a, pk_a)]);
let origin_caps = CapabilitySet::from_kinds(vec![CapabilityKind::ViewPrivate]);
let attempted = CapabilitySet::from_kinds(vec![
CapabilityKind::ViewPrivate,
CapabilityKind::EditPrivatePost,
]);
let entry = build_signed_entry(
&did_a, kid_a, &sk_a,
&did_b, kid_b, pk_b,
attempted,
);
let chain = AttributionChainWire {
origin: AttributionPrincipal::Service(ServiceIdentity::new_internal(
did_a.clone(),
kid_a,
pk_a,
None,
)),
entries: smallvec::smallvec![entry],
};
let err = verify_attribution_chain(
&chain,
&origin_caps,
resolver.as_ref() as &dyn DidResolver,
deadline(),
TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
match err {
BindError::AttributionReceiptInvalid {
failing_hop,
reason: ReceiptVerificationFailure::CapabilityExpansion { hop, .. },
} => {
assert_eq!(failing_hop, 0);
assert_eq!(hop, 0);
}
other => panic!("expected CapabilityExpansion at hop 0, got {other:?}"),
}
}
#[tokio::test]
async fn verify_attribution_chain_signature_invalid() {
let (sk_a, pk_a, kid_a, did_a) = chain_test_pair(0xA2);
let (sk_imposter, _, _, _) = chain_test_pair(0xC2);
let (_sk_b, pk_b, kid_b, did_b) = chain_test_pair(0xB2);
let resolver = chain_test_resolver_for(&[(did_a.clone(), kid_a, pk_a)]);
let origin_caps = CapabilitySet::from_kinds(vec![CapabilityKind::ViewPrivate]);
let entry = build_signed_entry(
&did_a, kid_a, &sk_imposter,
&did_b, kid_b, pk_b,
origin_caps.clone(),
);
let _ = &sk_a;
let chain = AttributionChainWire {
origin: AttributionPrincipal::Service(ServiceIdentity::new_internal(
did_a.clone(), kid_a, pk_a, None,
)),
entries: smallvec::smallvec![entry],
};
let err = verify_attribution_chain(
&chain, &origin_caps,
resolver.as_ref() as &dyn DidResolver,
deadline(), TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(
err,
BindError::AttributionReceiptInvalid {
reason: ReceiptVerificationFailure::SignatureInvalid,
..
}
));
}
#[tokio::test]
async fn verify_attribution_chain_key_not_in_rotation_history() {
let (sk_a, _pk_a, kid_a, did_a) = chain_test_pair(0xA3);
let pk_a_es256 = PublicKey {
algorithm: SignatureAlgorithm::Es256,
bytes: [0x33; 32],
};
let (_sk_b, pk_b, kid_b, did_b) = chain_test_pair(0xB3);
let resolver = chain_test_resolver_for(&[(did_a.clone(), kid_a, pk_a_es256)]);
let origin_caps = CapabilitySet::from_kinds(vec![CapabilityKind::ViewPrivate]);
let entry = build_signed_entry(
&did_a, kid_a, &sk_a,
&did_b, kid_b, pk_b,
origin_caps.clone(),
);
let chain = AttributionChainWire {
origin: AttributionPrincipal::Service(ServiceIdentity::new_internal(
did_a.clone(), kid_a, pk_a_es256, None,
)),
entries: smallvec::smallvec![entry],
};
let err = verify_attribution_chain(
&chain, &origin_caps,
resolver.as_ref() as &dyn DidResolver,
deadline(), TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(
err,
BindError::AttributionReceiptInvalid {
reason: ReceiptVerificationFailure::KeyNotInRotationHistory { .. },
..
}
));
}
#[tokio::test]
async fn verify_attribution_chain_previous_principal_unresolvable() {
let (sk_a, pk_a, kid_a, did_a) = chain_test_pair(0xA4);
let (_sk_b, pk_b, kid_b, did_b) = chain_test_pair(0xB4);
let r = Arc::new(MockResolver::new());
r.insert_err(&did_a, DidResolutionError::NotFound);
let origin_caps = CapabilitySet::from_kinds(vec![CapabilityKind::ViewPrivate]);
let entry = build_signed_entry(
&did_a, kid_a, &sk_a,
&did_b, kid_b, pk_b,
origin_caps.clone(),
);
let chain = AttributionChainWire {
origin: AttributionPrincipal::Service(ServiceIdentity::new_internal(
did_a.clone(), kid_a, pk_a, None,
)),
entries: smallvec::smallvec![entry],
};
let err = verify_attribution_chain(
&chain, &origin_caps,
r.as_ref() as &dyn DidResolver,
deadline(), TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(
err,
BindError::AttributionReceiptInvalid {
reason: ReceiptVerificationFailure::PreviousPrincipalUnresolvable(_),
..
}
));
}
#[tokio::test]
async fn verify_attribution_chain_algorithm_not_accepted() {
let (sk_a, pk_a, kid_a, did_a) = chain_test_pair(0xA5);
let (_sk_b, pk_b, kid_b, did_b) = chain_test_pair(0xB5);
let resolver = chain_test_resolver_for(&[(did_a.clone(), kid_a, pk_a)]);
let origin_caps = CapabilitySet::from_kinds(vec![CapabilityKind::ViewPrivate]);
let mut entry = build_signed_entry(
&did_a, kid_a, &sk_a,
&did_b, kid_b, pk_b,
origin_caps.clone(),
);
entry.receipt = DelegationReceipt {
algorithm: SignatureAlgorithm::Es256,
bytes: entry.receipt.bytes,
};
let chain = AttributionChainWire {
origin: AttributionPrincipal::Service(ServiceIdentity::new_internal(
did_a.clone(), kid_a, pk_a, None,
)),
entries: smallvec::smallvec![entry],
};
let err = verify_attribution_chain(
&chain, &origin_caps,
resolver.as_ref() as &dyn DidResolver,
deadline(), TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(
err,
BindError::AttributionReceiptInvalid {
reason: ReceiptVerificationFailure::AlgorithmNotAccepted(SignatureAlgorithm::Es256),
..
}
));
}
#[tokio::test]
async fn verify_attribution_chain_over_depth_returns_malformed() {
let (sk_a, pk_a, kid_a, did_a) = chain_test_pair(0xA6);
let (_, pk_b, kid_b, did_b) = chain_test_pair(0xB6);
let resolver = chain_test_resolver_for(&[(did_a.clone(), kid_a, pk_a)]);
let origin_caps = CapabilitySet::from_kinds(vec![CapabilityKind::ViewPrivate]);
let entry = build_signed_entry(
&did_a, kid_a, &sk_a,
&did_b, kid_b, pk_b,
origin_caps.clone(),
);
let mut entries: smallvec::SmallVec<[AttributionEntryWire; 8]> =
smallvec::SmallVec::new();
for _ in 0..(crate::ingress::MAX_CHAIN_DEPTH + 1) {
entries.push(entry.clone());
}
let chain = AttributionChainWire {
origin: AttributionPrincipal::Service(ServiceIdentity::new_internal(
did_a.clone(), kid_a, pk_a, None,
)),
entries,
};
let err = verify_attribution_chain(
&chain, &origin_caps,
resolver.as_ref() as &dyn DidResolver,
deadline(), TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(
err,
BindError::AttributionReceiptInvalid {
reason: ReceiptVerificationFailure::Malformed,
..
}
));
}
fn build_valid_hello(
seed: u8,
) -> (Vec<u8>, ServiceIdentity, SigningKey) {
let (initiator, sk) = make_initiator_identity(seed);
let nonce = SessionNonce::from_bytes([seed.wrapping_mul(3); 32]);
let scope = SyncRequestedScope {
nsids: smallvec::SmallVec::new(),
time_window: None,
direction: crate::wire::SyncDirection::Bidirectional,
};
let at = SystemTime::now();
let sign_input = crate::wire::hello_sign_input(
&initiator,
SemVer::new(1, 0, 0),
&nonce,
&scope,
at,
);
let sig = crate::wire::sign_handshake_payload(&sk, &sign_input);
let hello = SyncChannelHello {
initiator_identity: initiator.clone(),
initiator_lexicon_set_version: SemVer::new(1, 0, 0),
proposed_session_nonce: nonce,
requested_scope: scope,
initiator_signature: sig,
at,
};
let bytes = hello_to_wire_bytes(&hello);
(bytes, initiator, sk)
}
#[tokio::test]
async fn verify_sync_hello_returns_malformed_for_garbage_bytes() {
let bytes = vec![0xFF, 0xFE, 0xFD, 0xFC];
let resolver = Arc::new(MockResolver::new());
let tracker = crate::wire::DefaultHandshakeNonceTracker::new();
let cfg = SyncHandshakeVerificationConfig::default();
let err = verify_sync_hello(
&bytes,
&tracker,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(err, SyncHandshakeVerificationError::Malformed));
}
#[tokio::test]
async fn verify_sync_hello_returns_too_large_above_size_ceiling() {
let bytes = vec![0u8; crate::wire::MAX_HANDSHAKE_MESSAGE_SIZE + 1];
let resolver = Arc::new(MockResolver::new());
let tracker = crate::wire::DefaultHandshakeNonceTracker::new();
let cfg = SyncHandshakeVerificationConfig::default();
let err = verify_sync_hello(
&bytes,
&tracker,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(err, SyncHandshakeVerificationError::TooLarge));
}
#[tokio::test]
async fn verify_sync_hello_returns_counterparty_resolution_failed() {
let (bytes, initiator, _sk) = build_valid_hello(0x30);
let resolver = Arc::new(MockResolver::new());
resolver.insert_err(initiator.service_did(), DidResolutionError::NotFound);
let tracker = crate::wire::DefaultHandshakeNonceTracker::new();
let cfg = SyncHandshakeVerificationConfig::default();
let err = verify_sync_hello(
&bytes,
&tracker,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(
err,
SyncHandshakeVerificationError::CounterpartyResolutionFailed(_)
));
}
#[tokio::test]
async fn verify_sync_hello_returns_counterparty_key_not_in_document() {
let (bytes, initiator, _sk) = build_valid_hello(0x31);
let resolver = Arc::new(MockResolver::new());
let other_kid = KeyId::from_bytes([0x99; 32]);
resolver.insert(initiator.service_did(), other_kid, *initiator.key_material());
let tracker = crate::wire::DefaultHandshakeNonceTracker::new();
let cfg = SyncHandshakeVerificationConfig::default();
let err = verify_sync_hello(
&bytes,
&tracker,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(
err,
SyncHandshakeVerificationError::CounterpartyKeyNotInDocument
));
}
#[tokio::test]
async fn verify_sync_hello_returns_unsupported_algorithm() {
let (bytes, initiator, _sk) = build_valid_hello(0x32);
let resolver = Arc::new(MockResolver::new());
resolver.insert(initiator.service_did(), initiator.key_id(), *initiator.key_material());
let tracker = crate::wire::DefaultHandshakeNonceTracker::new();
let cfg = SyncHandshakeVerificationConfig {
accepted_algorithms: &[],
..SyncHandshakeVerificationConfig::default()
};
let err = verify_sync_hello(
&bytes,
&tracker,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(
err,
SyncHandshakeVerificationError::UnsupportedAlgorithm(SignatureAlgorithm::Ed25519)
));
}
#[tokio::test]
async fn verify_sync_hello_returns_nonce_tracker_backend() {
struct FailingTracker;
impl crate::wire::HandshakeNonceTracker for FailingTracker {
fn check_and_record(
&self,
_initiator: &ServiceIdentity,
_nonce: &SessionNonce,
_observed_at: SystemTime,
) -> Result<crate::wire::NonceFreshness, NonceTrackerError> {
Err(NonceTrackerError::BackendUnavailable)
}
fn replay_window(&self) -> Duration {
Duration::from_secs(60)
}
}
let (bytes, initiator, _sk) = build_valid_hello(0x33);
let resolver = Arc::new(MockResolver::new());
resolver.insert(initiator.service_did(), initiator.key_id(), *initiator.key_material());
let cfg = SyncHandshakeVerificationConfig::default();
let err = verify_sync_hello(
&bytes,
&FailingTracker,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(
err,
SyncHandshakeVerificationError::NonceTrackerBackend(_)
));
}
#[tokio::test]
async fn verify_sync_hello_returns_not_yet_valid() {
let (initiator, sk) = make_initiator_identity(0x34);
let resolver = handshake_test_resolver(&initiator);
let tracker = crate::wire::DefaultHandshakeNonceTracker::new();
let cfg = SyncHandshakeVerificationConfig::default();
let nonce = SessionNonce::from_bytes([0xAA; 32]);
let scope = SyncRequestedScope {
nsids: smallvec::SmallVec::new(),
time_window: None,
direction: crate::wire::SyncDirection::Bidirectional,
};
let at = SystemTime::now() + Duration::from_secs(3600);
let sign_input = crate::wire::hello_sign_input(&initiator, SemVer::new(1, 0, 0), &nonce, &scope, at);
let sig = crate::wire::sign_handshake_payload(&sk, &sign_input);
let hello = SyncChannelHello {
initiator_identity: initiator,
initiator_lexicon_set_version: SemVer::new(1, 0, 0),
proposed_session_nonce: nonce,
requested_scope: scope,
initiator_signature: sig,
at,
};
let bytes = hello_to_wire_bytes(&hello);
let err = verify_sync_hello(
&bytes,
&tracker,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(err, SyncHandshakeVerificationError::NotYetValid));
}
#[tokio::test]
async fn verify_sync_hello_returns_too_old() {
let (initiator, sk) = make_initiator_identity(0x35);
let resolver = handshake_test_resolver(&initiator);
let tracker = crate::wire::DefaultHandshakeNonceTracker::new();
let cfg = SyncHandshakeVerificationConfig::default();
let nonce = SessionNonce::from_bytes([0xBB; 32]);
let scope = SyncRequestedScope {
nsids: smallvec::SmallVec::new(),
time_window: None,
direction: crate::wire::SyncDirection::Bidirectional,
};
let at = SystemTime::now() - Duration::from_secs(3600);
let sign_input = crate::wire::hello_sign_input(&initiator, SemVer::new(1, 0, 0), &nonce, &scope, at);
let sig = crate::wire::sign_handshake_payload(&sk, &sign_input);
let hello = SyncChannelHello {
initiator_identity: initiator,
initiator_lexicon_set_version: SemVer::new(1, 0, 0),
proposed_session_nonce: nonce,
requested_scope: scope,
initiator_signature: sig,
at,
};
let bytes = hello_to_wire_bytes(&hello);
let err = verify_sync_hello(
&bytes,
&tracker,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(err, SyncHandshakeVerificationError::TooOld));
}
#[tokio::test]
async fn verify_sync_response_returns_counterparty_identity_mismatch() {
let (responder_a, sk_a) = make_initiator_identity(0x40);
let resolver = handshake_test_resolver(&responder_a);
let cfg = SyncHandshakeVerificationConfig::default();
let session_id = SessionId::from_bytes([0x77; 32]);
let scope = SyncRequestedScope {
nsids: smallvec::SmallVec::new(),
time_window: None,
direction: crate::wire::SyncDirection::Bidirectional,
};
let at = SystemTime::now();
let sign_input = crate::wire::accept_sign_input(
&responder_a, SemVer::new(1, 0, 0), &session_id, &scope, at,
);
let sig = crate::wire::sign_handshake_payload(&sk_a, &sign_input);
let accept = SyncChannelAccept {
responder_identity: responder_a.clone(),
responder_lexicon_set_version: SemVer::new(1, 0, 0),
session_id,
negotiated_scope: scope,
responder_signature: sig,
at,
};
let mut bytes = vec![0x00];
bytes.extend(accept_to_wire_bytes(&accept));
let did_b = Did::new("did:plc:differentresponder000").unwrap();
let err = verify_sync_response(
&bytes,
&did_b,
resolver.as_ref() as &dyn DidResolver,
&cfg,
deadline(),
TraceId::from_bytes([0; 16]),
)
.await
.unwrap_err();
assert!(matches!(
err,
SyncHandshakeVerificationError::CounterpartyIdentityMismatch
));
}
}