use core::marker::PhantomData;
use std::time::{Duration, SystemTime};
use ciborium::Value;
use ed25519_dalek::Signer;
use thiserror::Error;
use crate::authority::capability::CapabilityKind;
use crate::authority::subjects::ResourceId;
use crate::identity::{KeyId, PublicKey, ServiceIdentity, SignatureAlgorithm, TraceId};
use crate::proto::Did;
use crate::sealed;
use super::canonical_cbor;
use super::nonce::ClaimNonce;
use super::receipt::{
AttributionChainWire, AttributionEntryWire, AttributionPrincipal, DelegationReceipt,
};
use super::signature::ClaimSignature;
pub const MAX_CLAIM_VALIDITY: Duration = Duration::from_secs(600);
pub const MAX_CAPABILITY_CLAIM_SIZE: usize = 4096;
pub(crate) const CLAIM_DOMAIN_TAG: &[u8] = b"kryphocron/v1/capability-claim/";
#[derive(Debug, Clone)]
pub struct CapabilityClaim {
issuer: ServiceIdentity,
audience: ServiceIdentity,
subject: Did,
origin: ClaimOrigin,
capabilities: Vec<CapabilityKind>,
resource_scope: ResourceScope,
nonce: ClaimNonce,
trace_id: TraceId,
issued_at: SystemTime,
expires_at: SystemTime,
signature: ClaimSignature,
_private: PhantomData<sealed::Token>,
}
impl CapabilityClaim {
pub fn new(
issuer: ServiceIdentity,
audience: ServiceIdentity,
subject: Did,
capabilities: Vec<CapabilityKind>,
resource_scope: ResourceScope,
nonce: ClaimNonce,
trace_id: TraceId,
validity: Duration,
signing_key: &ed25519_dalek::SigningKey,
) -> Result<Self, ClaimConstructionError> {
if validity > MAX_CLAIM_VALIDITY {
return Err(ClaimConstructionError::ValidityTooLong {
requested: validity,
max: MAX_CLAIM_VALIDITY,
});
}
for cap in &capabilities {
if !cap.is_wire_eligible() {
return Err(ClaimConstructionError::NonWireEligibleCapability(*cap));
}
}
for cap in &capabilities {
check_scope_for_class(*cap, &resource_scope)?;
}
let derived_public = signing_key.verifying_key().to_bytes();
if derived_public != issuer.key_material().bytes {
return Err(ClaimConstructionError::SigningFailed);
}
let now = SystemTime::now();
let issued_at = now;
let expires_at = now + validity;
let origin = ClaimOrigin::SelfOriginated;
let canonical_bytes = encode_payload(
&issuer,
&audience,
&subject,
&origin,
&capabilities,
&resource_scope,
&nonce,
&trace_id,
issued_at,
expires_at,
);
if canonical_bytes.len() > MAX_CAPABILITY_CLAIM_SIZE {
return Err(ClaimConstructionError::ClaimTooLarge {
size: canonical_bytes.len(),
max: MAX_CAPABILITY_CLAIM_SIZE,
});
}
let mut signing_input =
Vec::with_capacity(CLAIM_DOMAIN_TAG.len() + canonical_bytes.len());
signing_input.extend_from_slice(CLAIM_DOMAIN_TAG);
signing_input.extend_from_slice(&canonical_bytes);
let sig = signing_key.sign(&signing_input);
let signature = ClaimSignature {
algorithm: SignatureAlgorithm::Ed25519,
bytes: sig.to_bytes(),
};
Ok(CapabilityClaim {
issuer,
audience,
subject,
origin,
capabilities,
resource_scope,
nonce,
trace_id,
issued_at,
expires_at,
signature,
_private: PhantomData,
})
}
#[allow(clippy::too_many_arguments)]
pub fn new_delegated(
issuer: ServiceIdentity,
audience: ServiceIdentity,
subject: Did,
capabilities: Vec<CapabilityKind>,
resource_scope: ResourceScope,
nonce: ClaimNonce,
trace_id: TraceId,
validity: Duration,
chain: AttributionChainWire,
signing_key: &ed25519_dalek::SigningKey,
) -> Result<Self, ClaimConstructionError> {
if chain.entries.is_empty() {
return Err(ClaimConstructionError::EmptyDelegationChain);
}
if validity > MAX_CLAIM_VALIDITY {
return Err(ClaimConstructionError::ValidityTooLong {
requested: validity,
max: MAX_CLAIM_VALIDITY,
});
}
for cap in &capabilities {
if !cap.is_wire_eligible() {
return Err(ClaimConstructionError::NonWireEligibleCapability(*cap));
}
}
for cap in &capabilities {
check_scope_for_class(*cap, &resource_scope)?;
}
let derived_public = signing_key.verifying_key().to_bytes();
if derived_public != issuer.key_material().bytes {
return Err(ClaimConstructionError::SigningFailed);
}
let now = SystemTime::now();
let issued_at = now;
let expires_at = now + validity;
let origin = ClaimOrigin::DelegatedFromUpstream { chain };
let canonical_bytes = encode_payload(
&issuer,
&audience,
&subject,
&origin,
&capabilities,
&resource_scope,
&nonce,
&trace_id,
issued_at,
expires_at,
);
if canonical_bytes.len() > MAX_CAPABILITY_CLAIM_SIZE {
return Err(ClaimConstructionError::ClaimTooLarge {
size: canonical_bytes.len(),
max: MAX_CAPABILITY_CLAIM_SIZE,
});
}
let mut signing_input =
Vec::with_capacity(CLAIM_DOMAIN_TAG.len() + canonical_bytes.len());
signing_input.extend_from_slice(CLAIM_DOMAIN_TAG);
signing_input.extend_from_slice(&canonical_bytes);
let sig = signing_key.sign(&signing_input);
let signature = ClaimSignature {
algorithm: SignatureAlgorithm::Ed25519,
bytes: sig.to_bytes(),
};
Ok(CapabilityClaim {
issuer,
audience,
subject,
origin,
capabilities,
resource_scope,
nonce,
trace_id,
issued_at,
expires_at,
signature,
_private: PhantomData,
})
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub(crate) fn new_internal_received(
issuer: ServiceIdentity,
audience: ServiceIdentity,
subject: Did,
origin: ClaimOrigin,
capabilities: Vec<CapabilityKind>,
resource_scope: ResourceScope,
nonce: ClaimNonce,
trace_id: TraceId,
issued_at: SystemTime,
expires_at: SystemTime,
signature: ClaimSignature,
) -> Self {
CapabilityClaim {
issuer,
audience,
subject,
origin,
capabilities,
resource_scope,
nonce,
trace_id,
issued_at,
expires_at,
signature,
_private: PhantomData,
}
}
#[must_use]
pub(crate) fn canonical_payload_bytes(&self) -> Vec<u8> {
encode_payload(
&self.issuer,
&self.audience,
&self.subject,
&self.origin,
&self.capabilities,
&self.resource_scope,
&self.nonce,
&self.trace_id,
self.issued_at,
self.expires_at,
)
}
#[must_use]
pub fn issuer(&self) -> &ServiceIdentity {
&self.issuer
}
#[must_use]
pub fn audience(&self) -> &ServiceIdentity {
&self.audience
}
#[must_use]
pub fn subject(&self) -> &Did {
&self.subject
}
#[must_use]
pub fn origin(&self) -> &ClaimOrigin {
&self.origin
}
#[must_use]
pub fn capabilities(&self) -> &[CapabilityKind] {
&self.capabilities
}
#[must_use]
pub fn resource_scope(&self) -> &ResourceScope {
&self.resource_scope
}
#[must_use]
pub fn nonce(&self) -> &ClaimNonce {
&self.nonce
}
#[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 signature(&self) -> &ClaimSignature {
&self.signature
}
}
#[allow(clippy::too_many_arguments)]
fn encode_payload(
issuer: &ServiceIdentity,
audience: &ServiceIdentity,
subject: &Did,
origin: &ClaimOrigin,
capabilities: &[CapabilityKind],
resource_scope: &ResourceScope,
nonce: &ClaimNonce,
trace_id: &TraceId,
issued_at: SystemTime,
expires_at: SystemTime,
) -> Vec<u8> {
let map = Value::Map(vec![
(Value::Text("issuer".into()), service_identity_value(issuer)),
(Value::Text("audience".into()), service_identity_value(audience)),
(Value::Text("subject".into()), Value::Text(subject.as_str().to_string())),
(Value::Text("origin".into()), claim_origin_value(origin)),
(Value::Text("capabilities".into()), capabilities_value(capabilities)),
(Value::Text("resource_scope".into()), resource_scope_value(resource_scope)),
(Value::Text("nonce".into()), Value::Bytes(nonce.as_bytes().to_vec())),
(Value::Text("trace_id".into()), Value::Bytes(trace_id.as_bytes().to_vec())),
(Value::Text("issued_at".into()), system_time_value(issued_at)),
(Value::Text("expires_at".into()), system_time_value(expires_at)),
]);
canonical_cbor::to_canonical_bytes(map)
}
fn service_identity_value(s: &ServiceIdentity) -> Value {
Value::Map(vec![
(Value::Text("did".into()), Value::Text(s.service_did().as_str().to_string())),
(Value::Text("key_id".into()), Value::Bytes(s.key_id().as_bytes().to_vec())),
(Value::Text("key_alg".into()), Value::Text(signature_alg_name(s.key_material().algorithm).into())),
(Value::Text("key_material".into()), Value::Bytes(s.key_material().bytes.to_vec())),
])
}
fn signature_alg_name(a: SignatureAlgorithm) -> &'static str {
match a {
SignatureAlgorithm::Ed25519 => "Ed25519",
SignatureAlgorithm::Es256 => "Es256",
SignatureAlgorithm::Es256K => "Es256K",
}
}
fn capabilities_value(caps: &[CapabilityKind]) -> Value {
Value::Array(
caps.iter()
.map(|c| Value::Text(c.wire_name().to_string()))
.collect(),
)
}
fn resource_scope_value(s: &ResourceScope) -> Value {
match s {
ResourceScope::Resource(rid) => Value::Map(vec![
(Value::Text("kind".into()), Value::Text("resource".into())),
(
Value::Text("value".into()),
Value::Map(vec![
(Value::Text("did".into()), Value::Text(rid.did().as_str().to_string())),
(Value::Text("nsid".into()), Value::Text(rid.nsid().as_str().to_string())),
(Value::Text("rkey".into()), Value::Text(rid.rkey().as_str().to_string())),
]),
),
]),
ResourceScope::AllResourcesOwnedBy(did) => Value::Map(vec![
(Value::Text("kind".into()), Value::Text("all_resources_owned_by".into())),
(Value::Text("value".into()), Value::Text(did.as_str().to_string())),
]),
ResourceScope::ClassWideAdministrative => Value::Map(vec![(
Value::Text("kind".into()),
Value::Text("class_wide_administrative".into()),
)]),
}
}
fn claim_origin_value(o: &ClaimOrigin) -> Value {
match o {
ClaimOrigin::SelfOriginated => Value::Map(vec![(
Value::Text("kind".into()),
Value::Text("self_originated".into()),
)]),
ClaimOrigin::DelegatedFromUpstream { chain } => Value::Map(vec![
(Value::Text("kind".into()), Value::Text("delegated_from_upstream".into())),
(Value::Text("chain".into()), attribution_chain_value(chain)),
]),
}
}
fn attribution_chain_value(c: &AttributionChainWire) -> Value {
Value::Map(vec![
(Value::Text("origin".into()), attribution_principal_value(&c.origin)),
(
Value::Text("entries".into()),
Value::Array(c.entries.iter().map(attribution_entry_value).collect()),
),
])
}
fn attribution_principal_value(p: &AttributionPrincipal) -> Value {
match p {
AttributionPrincipal::User(did) => Value::Map(vec![
(Value::Text("kind".into()), Value::Text("user".into())),
(Value::Text("did".into()), Value::Text(did.as_str().to_string())),
]),
AttributionPrincipal::Service(s) => Value::Map(vec![
(Value::Text("kind".into()), Value::Text("service".into())),
(Value::Text("identity".into()), service_identity_value(s)),
]),
}
}
fn attribution_entry_value(e: &AttributionEntryWire) -> Value {
Value::Map(vec![
(Value::Text("principal".into()), attribution_principal_value(&e.principal)),
(
Value::Text("derivation_reason".into()),
derivation_reason_value(&e.derivation_reason),
),
(Value::Text("derived_at".into()), system_time_value(e.derived_at)),
(
Value::Text("granted_capabilities".into()),
Value::Array(
e.granted_capabilities
.kinds()
.iter()
.map(|c| Value::Text(c.wire_name().to_string()))
.collect(),
),
),
(Value::Text("receipt".into()), receipt_value(&e.receipt)),
])
}
fn derivation_reason_value(r: &crate::ingress::DerivationReason) -> Value {
use crate::ingress::DerivationReason;
match r {
DerivationReason::DropPrivilegeToAnonymous => Value::Map(vec![(
Value::Text("kind".into()),
Value::Text("drop_privilege_to_anonymous".into()),
)]),
DerivationReason::NarrowCapabilities { dropped } => Value::Map(vec![
(Value::Text("kind".into()), Value::Text("narrow_capabilities".into())),
(
Value::Text("dropped".into()),
Value::Array(
dropped
.kinds()
.iter()
.map(|c| Value::Text(c.wire_name().to_string()))
.collect(),
),
),
]),
DerivationReason::ServiceToServiceDelegation { trust_declaration_id } => {
Value::Map(vec![
(
Value::Text("kind".into()),
Value::Text("service_to_service_delegation".into()),
),
(
Value::Text("trust_declaration_id".into()),
Value::Bytes(trust_declaration_id.as_bytes().to_vec()),
),
])
}
}
}
fn receipt_value(r: &DelegationReceipt) -> Value {
Value::Map(vec![
(Value::Text("alg".into()), Value::Text(signature_alg_name(r.algorithm).into())),
(Value::Text("bytes".into()), Value::Bytes(r.bytes.to_vec())),
])
}
fn system_time_value(t: SystemTime) -> Value {
let secs = t
.duration_since(SystemTime::UNIX_EPOCH)
.expect("SystemTime before UNIX_EPOCH not supported")
.as_secs();
Value::Integer(secs.into())
}
#[allow(clippy::type_complexity, clippy::too_many_lines, dead_code)]
pub(crate) fn decode_payload(
bytes: &[u8],
) -> Result<
(
ServiceIdentity, // issuer
ServiceIdentity, // audience
Did, // subject
ClaimOrigin, // origin
Vec<CapabilityKind>,
ResourceScope,
ClaimNonce,
TraceId,
SystemTime, // issued_at
SystemTime, // expires_at
),
(),
> {
let value = canonical_cbor::from_bytes(bytes)?;
let map = into_map(&value)?;
Ok((
decode_service_identity(map_get(&map, "issuer")?)?,
decode_service_identity(map_get(&map, "audience")?)?,
decode_did(map_get(&map, "subject")?)?,
decode_claim_origin(map_get(&map, "origin")?)?,
decode_capabilities(map_get(&map, "capabilities")?)?,
decode_resource_scope(map_get(&map, "resource_scope")?)?,
decode_nonce(map_get(&map, "nonce")?)?,
decode_trace_id(map_get(&map, "trace_id")?)?,
decode_system_time(map_get(&map, "issued_at")?)?,
decode_system_time(map_get(&map, "expires_at")?)?,
))
}
impl CapabilityClaim {
#[must_use]
pub fn to_wire_bytes(&self) -> Vec<u8> {
let map = Value::Map(vec![
(Value::Text("issuer".into()), service_identity_value(&self.issuer)),
(Value::Text("audience".into()), service_identity_value(&self.audience)),
(
Value::Text("subject".into()),
Value::Text(self.subject.as_str().to_string()),
),
(Value::Text("origin".into()), claim_origin_value(&self.origin)),
(
Value::Text("capabilities".into()),
capabilities_value(&self.capabilities),
),
(
Value::Text("resource_scope".into()),
resource_scope_value(&self.resource_scope),
),
(
Value::Text("nonce".into()),
Value::Bytes(self.nonce.as_bytes().to_vec()),
),
(
Value::Text("trace_id".into()),
Value::Bytes(self.trace_id.as_bytes().to_vec()),
),
(Value::Text("issued_at".into()), system_time_value(self.issued_at)),
(Value::Text("expires_at".into()), system_time_value(self.expires_at)),
(Value::Text("signature".into()), claim_signature_value(&self.signature)),
]);
canonical_cbor::to_canonical_bytes(map)
}
}
fn claim_signature_value(s: &ClaimSignature) -> Value {
Value::Map(vec![
(Value::Text("alg".into()), Value::Text(signature_alg_name(s.algorithm).into())),
(Value::Text("bytes".into()), Value::Bytes(s.bytes.to_vec())),
])
}
#[allow(clippy::type_complexity)]
pub(crate) fn decode_wire(
bytes: &[u8],
) -> Result<
(
ServiceIdentity,
ServiceIdentity,
Did,
ClaimOrigin,
Vec<CapabilityKind>,
ResourceScope,
ClaimNonce,
TraceId,
SystemTime,
SystemTime,
ClaimSignature,
),
(),
> {
let value = canonical_cbor::from_bytes(bytes)?;
let map = into_map(&value)?;
let signature = decode_claim_signature(map_get(&map, "signature")?)?;
Ok((
decode_service_identity(map_get(&map, "issuer")?)?,
decode_service_identity(map_get(&map, "audience")?)?,
decode_did(map_get(&map, "subject")?)?,
decode_claim_origin(map_get(&map, "origin")?)?,
decode_capabilities(map_get(&map, "capabilities")?)?,
decode_resource_scope(map_get(&map, "resource_scope")?)?,
decode_nonce(map_get(&map, "nonce")?)?,
decode_trace_id(map_get(&map, "trace_id")?)?,
decode_system_time(map_get(&map, "issued_at")?)?,
decode_system_time(map_get(&map, "expires_at")?)?,
signature,
))
}
fn decode_claim_signature(v: &Value) -> Result<ClaimSignature, ()> {
let map = into_map(v)?;
let alg_str = match map_get(&map, "alg")? {
Value::Text(s) => s.as_str(),
_ => return Err(()),
};
let algorithm = decode_signature_alg(alg_str).ok_or(())?;
let bytes = decode_bytes(map_get(&map, "bytes")?)?;
let bytes_arr: [u8; 64] = bytes.try_into().map_err(|_| ())?;
Ok(ClaimSignature {
algorithm,
bytes: bytes_arr,
})
}
#[must_use]
pub(crate) fn wire_bytes_are_canonical(bytes: &[u8]) -> bool {
let Ok(value) = canonical_cbor::from_bytes(bytes) else {
return false;
};
let re_encoded = canonical_cbor::to_canonical_bytes(value);
re_encoded == bytes
}
fn into_map(v: &Value) -> Result<Vec<(Value, Value)>, ()> {
match v {
Value::Map(entries) => Ok(entries.clone()),
_ => Err(()),
}
}
fn map_get<'a>(map: &'a [(Value, Value)], key: &str) -> Result<&'a Value, ()> {
map.iter()
.find_map(|(k, v)| match k {
Value::Text(s) if s == key => Some(v),
_ => None,
})
.ok_or(())
}
fn decode_did(v: &Value) -> Result<Did, ()> {
match v {
Value::Text(s) => Did::new(s).map_err(|_| ()),
_ => Err(()),
}
}
fn decode_service_identity(v: &Value) -> Result<ServiceIdentity, ()> {
let map = into_map(v)?;
let did = decode_did(map_get(&map, "did")?)?;
let key_id_bytes = decode_bytes(map_get(&map, "key_id")?)?;
let key_id_arr: [u8; 32] = key_id_bytes.try_into().map_err(|_| ())?;
let key_alg_str = match map_get(&map, "key_alg")? {
Value::Text(s) => s.as_str(),
_ => return Err(()),
};
let algorithm = decode_signature_alg(key_alg_str).ok_or(())?;
let key_material_bytes = decode_bytes(map_get(&map, "key_material")?)?;
let key_material_arr: [u8; 32] = key_material_bytes.try_into().map_err(|_| ())?;
Ok(ServiceIdentity::new_internal(
did,
KeyId::from_bytes(key_id_arr),
PublicKey {
algorithm,
bytes: key_material_arr,
},
None,
))
}
fn decode_signature_alg(s: &str) -> Option<SignatureAlgorithm> {
Some(match s {
"Ed25519" => SignatureAlgorithm::Ed25519,
"Es256" => SignatureAlgorithm::Es256,
"Es256K" => SignatureAlgorithm::Es256K,
_ => return None,
})
}
fn decode_bytes(v: &Value) -> Result<Vec<u8>, ()> {
match v {
Value::Bytes(b) => Ok(b.clone()),
_ => Err(()),
}
}
fn decode_capabilities(v: &Value) -> Result<Vec<CapabilityKind>, ()> {
match v {
Value::Array(items) => items
.iter()
.map(|item| match item {
Value::Text(s) => CapabilityKind::from_wire_name(s).ok_or(()),
_ => Err(()),
})
.collect(),
_ => Err(()),
}
}
fn decode_resource_scope(v: &Value) -> Result<ResourceScope, ()> {
let map = into_map(v)?;
let kind = match map_get(&map, "kind")? {
Value::Text(s) => s.as_str(),
_ => return Err(()),
};
match kind {
"resource" => {
let value_map = into_map(map_get(&map, "value")?)?;
let did = decode_did(map_get(&value_map, "did")?)?;
let nsid_str = match map_get(&value_map, "nsid")? {
Value::Text(s) => s.as_str(),
_ => return Err(()),
};
let nsid = crate::Nsid::new(nsid_str).map_err(|_| ())?;
let rkey_str = match map_get(&value_map, "rkey")? {
Value::Text(s) => s.as_str(),
_ => return Err(()),
};
let rkey = crate::Rkey::new(rkey_str).map_err(|_| ())?;
Ok(ResourceScope::Resource(ResourceId::new(did, nsid, rkey)))
}
"all_resources_owned_by" => {
let did = decode_did(map_get(&map, "value")?)?;
Ok(ResourceScope::AllResourcesOwnedBy(did))
}
"class_wide_administrative" => Ok(ResourceScope::ClassWideAdministrative),
_ => Err(()),
}
}
fn decode_claim_origin(v: &Value) -> Result<ClaimOrigin, ()> {
let map = into_map(v)?;
let kind = match map_get(&map, "kind")? {
Value::Text(s) => s.as_str(),
_ => return Err(()),
};
match kind {
"self_originated" => Ok(ClaimOrigin::SelfOriginated),
"delegated_from_upstream" => {
let chain = decode_attribution_chain(map_get(&map, "chain")?)?;
if chain.entries.is_empty() {
return Err(());
}
Ok(ClaimOrigin::DelegatedFromUpstream { chain })
}
_ => Err(()),
}
}
fn decode_attribution_chain(v: &Value) -> Result<AttributionChainWire, ()> {
let m = into_map(v)?;
let origin = decode_attribution_principal(map_get(&m, "origin")?)?;
let entries_value = map_get(&m, "entries")?;
let entries_arr = match entries_value {
Value::Array(a) => a,
_ => return Err(()),
};
if entries_arr.len() > crate::ingress::MAX_CHAIN_DEPTH {
return Err(());
}
let mut entries: smallvec::SmallVec<[AttributionEntryWire; crate::ingress::MAX_CHAIN_DEPTH]> =
smallvec::SmallVec::new();
for item in entries_arr {
entries.push(decode_attribution_entry(item)?);
}
Ok(AttributionChainWire { origin, entries })
}
fn decode_attribution_principal(v: &Value) -> Result<AttributionPrincipal, ()> {
let m = into_map(v)?;
let kind = match map_get(&m, "kind")? {
Value::Text(s) => s.as_str(),
_ => return Err(()),
};
match kind {
"user" => {
let did = decode_did(map_get(&m, "did")?)?;
Ok(AttributionPrincipal::User(did))
}
"service" => {
let identity = decode_service_identity(map_get(&m, "identity")?)?;
Ok(AttributionPrincipal::Service(identity))
}
_ => Err(()),
}
}
fn decode_attribution_entry(v: &Value) -> Result<AttributionEntryWire, ()> {
let m = into_map(v)?;
let principal = decode_attribution_principal(map_get(&m, "principal")?)?;
let derivation_reason =
decode_derivation_reason(map_get(&m, "derivation_reason")?)?;
let derived_at = decode_system_time(map_get(&m, "derived_at")?)?;
let granted_capabilities = decode_capability_set(map_get(&m, "granted_capabilities")?)?;
let receipt = decode_delegation_receipt(map_get(&m, "receipt")?)?;
Ok(AttributionEntryWire {
principal,
derivation_reason,
derived_at,
granted_capabilities,
receipt,
})
}
fn decode_derivation_reason(v: &Value) -> Result<crate::ingress::DerivationReason, ()> {
use crate::ingress::DerivationReason;
let m = into_map(v)?;
let kind = match map_get(&m, "kind")? {
Value::Text(s) => s.as_str(),
_ => return Err(()),
};
match kind {
"drop_privilege_to_anonymous" => Ok(DerivationReason::DropPrivilegeToAnonymous),
"narrow_capabilities" => {
let dropped = decode_capability_set(map_get(&m, "dropped")?)?;
Ok(DerivationReason::NarrowCapabilities { dropped })
}
"service_to_service_delegation" => {
let id_bytes = match map_get(&m, "trust_declaration_id")? {
Value::Bytes(b) if b.len() == 16 => {
let mut a = [0u8; 16];
a.copy_from_slice(b);
a
}
_ => return Err(()),
};
Ok(DerivationReason::ServiceToServiceDelegation {
trust_declaration_id: crate::ingress::TrustDeclarationId::from_bytes(id_bytes),
})
}
_ => Err(()),
}
}
fn decode_capability_set(
v: &Value,
) -> Result<crate::authority::capability::CapabilitySet, ()> {
let arr = match v {
Value::Array(a) => a,
_ => return Err(()),
};
let mut kinds: Vec<CapabilityKind> = Vec::with_capacity(arr.len());
for item in arr {
let s = match item {
Value::Text(s) => s.as_str(),
_ => return Err(()),
};
kinds.push(CapabilityKind::from_wire_name(s).ok_or(())?);
}
Ok(crate::authority::capability::CapabilitySet::from_kinds(kinds))
}
fn decode_delegation_receipt(v: &Value) -> Result<DelegationReceipt, ()> {
let m = into_map(v)?;
let alg = match map_get(&m, "alg")? {
Value::Text(s) => decode_signature_alg(s).ok_or(())?,
_ => return Err(()),
};
let bytes: [u8; 64] = match map_get(&m, "bytes")? {
Value::Bytes(b) if b.len() == 64 => {
let mut a = [0u8; 64];
a.copy_from_slice(b);
a
}
_ => return Err(()),
};
Ok(DelegationReceipt {
algorithm: alg,
bytes,
})
}
fn decode_nonce(v: &Value) -> Result<ClaimNonce, ()> {
let bytes = decode_bytes(v)?;
let arr: [u8; 16] = bytes.try_into().map_err(|_| ())?;
Ok(ClaimNonce::from_bytes(arr))
}
fn decode_trace_id(v: &Value) -> Result<TraceId, ()> {
let bytes = decode_bytes(v)?;
let arr: [u8; 16] = bytes.try_into().map_err(|_| ())?;
Ok(TraceId::from_bytes(arr))
}
fn decode_system_time(v: &Value) -> Result<SystemTime, ()> {
match v {
Value::Integer(i) => {
let secs: u64 = (*i).try_into().map_err(|_| ())?;
Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(secs))
}
_ => Err(()),
}
}
fn check_scope_for_class(
cap: CapabilityKind,
scope: &ResourceScope,
) -> Result<(), ClaimConstructionError> {
use crate::authority::capability::CapabilityClass;
match cap.class() {
CapabilityClass::User => match scope {
ResourceScope::Resource(_) => Ok(()),
other => Err(ClaimConstructionError::ScopeNotPermittedForClass {
capability: cap,
scope_variant: ScopeVariantName::from(other),
}),
},
CapabilityClass::Channel => match scope {
ResourceScope::Resource(_)
| ResourceScope::AllResourcesOwnedBy(_)
| ResourceScope::ClassWideAdministrative => Ok(()),
},
CapabilityClass::Substrate | CapabilityClass::Moderation => {
Err(ClaimConstructionError::NonWireEligibleCapability(cap))
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClaimOrigin {
SelfOriginated,
DelegatedFromUpstream {
chain: AttributionChainWire,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResourceScope {
Resource(ResourceId),
AllResourcesOwnedBy(Did),
ClassWideAdministrative,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScopeVariantName {
Resource,
AllResourcesOwnedBy,
ClassWideAdministrative,
}
impl From<&ResourceScope> for ScopeVariantName {
fn from(s: &ResourceScope) -> Self {
match s {
ResourceScope::Resource(_) => ScopeVariantName::Resource,
ResourceScope::AllResourcesOwnedBy(_) => ScopeVariantName::AllResourcesOwnedBy,
ResourceScope::ClassWideAdministrative => {
ScopeVariantName::ClassWideAdministrative
}
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ClaimConstructionError {
#[error("capability {0:?} is never wire-eligible")]
NonWireEligibleCapability(CapabilityKind),
#[error("capability {capability:?} does not permit scope variant {scope_variant:?}")]
ScopeNotPermittedForClass {
capability: CapabilityKind,
scope_variant: ScopeVariantName,
},
#[error("requested validity {requested:?} exceeds max {max:?}")]
ValidityTooLong {
requested: Duration,
max: Duration,
},
#[error("signing failed")]
SigningFailed,
#[error("rationale length {len} exceeds max {max}")]
RationaleTooLong {
len: usize,
max: usize,
},
#[error("claim size {size} exceeds max {max}")]
ClaimTooLarge {
size: usize,
max: usize,
},
#[error("delegation chain has zero entries")]
EmptyDelegationChain,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authority::capability::CapabilityKind;
use crate::authority::subjects::ResourceId;
use crate::identity::{KeyId, PublicKey, ServiceIdentity, SignatureAlgorithm};
use crate::proto::{Did, Nsid, Rkey};
use ed25519_dalek::SigningKey;
fn fixed_signing_key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32])
}
fn fixed_service_identity(did: &str) -> ServiceIdentity {
let signing = fixed_signing_key();
ServiceIdentity::new_internal(
Did::new(did).unwrap(),
KeyId::from_bytes([1u8; 32]),
PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: signing.verifying_key().to_bytes(),
},
None,
)
}
fn unmatched_service_identity(did: &str) -> ServiceIdentity {
let other = SigningKey::from_bytes(&[42u8; 32]);
ServiceIdentity::new_internal(
Did::new(did).unwrap(),
KeyId::from_bytes([2u8; 32]),
PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: other.verifying_key().to_bytes(),
},
None,
)
}
fn sample_resource() -> ResourceId {
ResourceId::new(
Did::new("did:plc:owner").unwrap(),
Nsid::new("tools.kryphocron.feed.postPrivate").unwrap(),
Rkey::new("samplerkey").unwrap(),
)
}
#[test]
fn validity_ceiling_pinned_at_600s() {
assert_eq!(MAX_CLAIM_VALIDITY, Duration::from_secs(600));
}
#[test]
fn max_capability_claim_size_pinned_at_4096() {
assert_eq!(MAX_CAPABILITY_CLAIM_SIZE, 4096);
}
#[test]
fn claim_domain_tag_pinned_per_4_8_w8() {
assert_eq!(CLAIM_DOMAIN_TAG, b"kryphocron/v1/capability-claim/");
}
#[test]
fn scope_variant_name_round_trips() {
let r = ResourceScope::ClassWideAdministrative;
assert_eq!(
ScopeVariantName::from(&r),
ScopeVariantName::ClassWideAdministrative
);
}
#[test]
fn happy_path_constructs_self_originated_claim() {
let signing = fixed_signing_key();
let issuer = fixed_service_identity("did:web:issuer.example");
let audience = fixed_service_identity("did:web:audience.example");
let claim = CapabilityClaim::new(
issuer,
audience,
Did::new("did:plc:subject").unwrap(),
vec![CapabilityKind::ViewPrivate],
ResourceScope::Resource(sample_resource()),
ClaimNonce::from_bytes([0xAB; 16]),
TraceId::from_bytes([0xCD; 16]),
Duration::from_secs(60),
&signing,
)
.unwrap();
assert!(matches!(claim.origin(), ClaimOrigin::SelfOriginated));
assert_eq!(claim.signature().algorithm, SignatureAlgorithm::Ed25519);
let bytes = claim.canonical_payload_bytes();
assert!(!bytes.is_empty());
assert!(bytes.len() <= MAX_CAPABILITY_CLAIM_SIZE);
}
#[test]
fn validity_too_long_returns_validity_too_long() {
let signing = fixed_signing_key();
let err = CapabilityClaim::new(
fixed_service_identity("did:web:i"),
fixed_service_identity("did:web:a"),
Did::new("did:plc:s").unwrap(),
vec![CapabilityKind::ViewPrivate],
ResourceScope::Resource(sample_resource()),
ClaimNonce::from_bytes([0; 16]),
TraceId::from_bytes([0; 16]),
MAX_CLAIM_VALIDITY + Duration::from_secs(1),
&signing,
)
.unwrap_err();
assert!(matches!(err, ClaimConstructionError::ValidityTooLong { .. }));
}
#[test]
fn substrate_capability_returns_non_wire_eligible() {
let signing = fixed_signing_key();
let err = CapabilityClaim::new(
fixed_service_identity("did:web:i"),
fixed_service_identity("did:web:a"),
Did::new("did:plc:s").unwrap(),
vec![CapabilityKind::ScanShard],
ResourceScope::ClassWideAdministrative,
ClaimNonce::from_bytes([0; 16]),
TraceId::from_bytes([0; 16]),
Duration::from_secs(60),
&signing,
)
.unwrap_err();
assert!(matches!(
err,
ClaimConstructionError::NonWireEligibleCapability(CapabilityKind::ScanShard)
));
}
#[test]
fn moderation_capability_returns_non_wire_eligible() {
let signing = fixed_signing_key();
let err = CapabilityClaim::new(
fixed_service_identity("did:web:i"),
fixed_service_identity("did:web:a"),
Did::new("did:plc:s").unwrap(),
vec![CapabilityKind::ModeratorRead],
ResourceScope::ClassWideAdministrative,
ClaimNonce::from_bytes([0; 16]),
TraceId::from_bytes([0; 16]),
Duration::from_secs(60),
&signing,
)
.unwrap_err();
assert!(matches!(
err,
ClaimConstructionError::NonWireEligibleCapability(CapabilityKind::ModeratorRead)
));
}
#[test]
fn user_capability_with_non_resource_scope_returns_scope_not_permitted() {
let signing = fixed_signing_key();
let err = CapabilityClaim::new(
fixed_service_identity("did:web:i"),
fixed_service_identity("did:web:a"),
Did::new("did:plc:s").unwrap(),
vec![CapabilityKind::ViewPrivate],
ResourceScope::ClassWideAdministrative,
ClaimNonce::from_bytes([0; 16]),
TraceId::from_bytes([0; 16]),
Duration::from_secs(60),
&signing,
)
.unwrap_err();
assert!(matches!(
err,
ClaimConstructionError::ScopeNotPermittedForClass {
capability: CapabilityKind::ViewPrivate,
scope_variant: ScopeVariantName::ClassWideAdministrative,
}
));
}
#[test]
fn mixed_class_with_user_and_channel_uses_most_restrictive() {
let signing = fixed_signing_key();
let err = CapabilityClaim::new(
fixed_service_identity("did:web:i"),
fixed_service_identity("did:web:a"),
Did::new("did:plc:s").unwrap(),
vec![CapabilityKind::ViewPrivate, CapabilityKind::EmitToSyncChannel],
ResourceScope::AllResourcesOwnedBy(Did::new("did:plc:owner").unwrap()),
ClaimNonce::from_bytes([0; 16]),
TraceId::from_bytes([0; 16]),
Duration::from_secs(60),
&signing,
)
.unwrap_err();
assert!(matches!(
err,
ClaimConstructionError::ScopeNotPermittedForClass { .. }
));
}
#[test]
fn issuer_signing_key_mismatch_returns_signing_failed() {
let signing = fixed_signing_key();
let issuer_with_other_key = unmatched_service_identity("did:web:i");
let err = CapabilityClaim::new(
issuer_with_other_key,
fixed_service_identity("did:web:a"),
Did::new("did:plc:s").unwrap(),
vec![CapabilityKind::ViewPrivate],
ResourceScope::Resource(sample_resource()),
ClaimNonce::from_bytes([0; 16]),
TraceId::from_bytes([0; 16]),
Duration::from_secs(60),
&signing,
)
.unwrap_err();
assert!(matches!(err, ClaimConstructionError::SigningFailed));
}
#[test]
fn canonical_payload_round_trips_through_decode() {
let signing = fixed_signing_key();
let issuer = fixed_service_identity("did:web:issuer.example");
let audience = fixed_service_identity("did:web:audience.example");
let claim = CapabilityClaim::new(
issuer,
audience,
Did::new("did:plc:subject").unwrap(),
vec![CapabilityKind::ViewPrivate, CapabilityKind::ParticipatePrivate],
ResourceScope::Resource(sample_resource()),
ClaimNonce::from_bytes([0xAB; 16]),
TraceId::from_bytes([0xCD; 16]),
Duration::from_secs(60),
&signing,
)
.unwrap();
let bytes = claim.canonical_payload_bytes();
let decoded = decode_payload(&bytes).unwrap();
let (
d_issuer,
d_audience,
d_subject,
d_origin,
d_capabilities,
d_resource_scope,
d_nonce,
d_trace_id,
d_issued_at,
d_expires_at,
) = decoded;
let re_encoded = encode_payload(
&d_issuer,
&d_audience,
&d_subject,
&d_origin,
&d_capabilities,
&d_resource_scope,
&d_nonce,
&d_trace_id,
d_issued_at,
d_expires_at,
);
assert_eq!(bytes, re_encoded);
}
use crate::wire::{sign_delegation_receipt, DelegationReceiptPayload};
fn build_one_hop_chain() -> AttributionChainWire {
let sk_a = SigningKey::from_bytes(&[0xA0u8; 32]);
let pk_a = PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: sk_a.verifying_key().to_bytes(),
};
let kid_a = KeyId::from_bytes([0xA0; 32]);
let did_a = Did::new("did:plc:originservice00000000").unwrap();
let identity_a = ServiceIdentity::new_internal(did_a.clone(), kid_a, pk_a, None);
let did_b = Did::new("did:plc:recipientservice00000").unwrap();
let kid_b = KeyId::from_bytes([0xB0; 32]);
let pk_b = PublicKey {
algorithm: SignatureAlgorithm::Ed25519,
bytes: SigningKey::from_bytes(&[0xB0u8; 32]).verifying_key().to_bytes(),
};
let identity_b = ServiceIdentity::new_internal(did_b.clone(), kid_b, pk_b, None);
let granted = crate::authority::capability::CapabilitySet::from_kinds(vec![
CapabilityKind::ViewPrivate,
]);
let derived_at = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let payload = DelegationReceiptPayload {
previous_principal_did: did_a.clone(),
previous_key_id: kid_a,
recipient_principal_did: did_b.clone(),
recipient_key_id: kid_b,
derivation_reason: crate::ingress::DerivationReason::DropPrivilegeToAnonymous,
granted_capabilities: granted.clone(),
derived_at,
};
let receipt = sign_delegation_receipt(&payload, &sk_a);
let entry = AttributionEntryWire {
principal: AttributionPrincipal::Service(identity_b),
derivation_reason: crate::ingress::DerivationReason::DropPrivilegeToAnonymous,
derived_at,
granted_capabilities: granted,
receipt,
};
AttributionChainWire {
origin: AttributionPrincipal::Service(identity_a),
entries: smallvec::smallvec![entry],
}
}
#[test]
fn new_delegated_constructs_with_one_hop_chain() {
let signing = fixed_signing_key();
let chain = build_one_hop_chain();
let claim = CapabilityClaim::new_delegated(
fixed_service_identity("did:web:i"),
fixed_service_identity("did:web:a"),
Did::new("did:plc:s").unwrap(),
vec![CapabilityKind::ViewPrivate],
ResourceScope::Resource(sample_resource()),
ClaimNonce::from_bytes([0; 16]),
TraceId::from_bytes([0; 16]),
Duration::from_secs(60),
chain,
&signing,
)
.unwrap();
assert!(matches!(claim.origin(), ClaimOrigin::DelegatedFromUpstream { .. }));
}
#[test]
fn new_delegated_rejects_empty_chain() {
let signing = fixed_signing_key();
let empty_chain = AttributionChainWire {
origin: AttributionPrincipal::User(Did::new("did:plc:u").unwrap()),
entries: smallvec::SmallVec::new(),
};
let err = CapabilityClaim::new_delegated(
fixed_service_identity("did:web:i"),
fixed_service_identity("did:web:a"),
Did::new("did:plc:s").unwrap(),
vec![CapabilityKind::ViewPrivate],
ResourceScope::Resource(sample_resource()),
ClaimNonce::from_bytes([0; 16]),
TraceId::from_bytes([0; 16]),
Duration::from_secs(60),
empty_chain,
&signing,
)
.unwrap_err();
assert!(matches!(err, ClaimConstructionError::EmptyDelegationChain));
}
#[test]
fn delegated_origin_wire_round_trip() {
let signing = fixed_signing_key();
let chain = build_one_hop_chain();
let claim = CapabilityClaim::new_delegated(
fixed_service_identity("did:web:i"),
fixed_service_identity("did:web:a"),
Did::new("did:plc:s").unwrap(),
vec![CapabilityKind::ViewPrivate],
ResourceScope::Resource(sample_resource()),
ClaimNonce::from_bytes([0; 16]),
TraceId::from_bytes([0; 16]),
Duration::from_secs(60),
chain,
&signing,
)
.unwrap();
let bytes = claim.canonical_payload_bytes();
let decoded = decode_payload(&bytes).unwrap();
let (
d_issuer, d_audience, d_subject, d_origin,
d_capabilities, d_resource_scope, d_nonce,
d_trace_id, d_issued_at, d_expires_at,
) = decoded;
assert!(matches!(d_origin, ClaimOrigin::DelegatedFromUpstream { .. }));
let re_encoded = encode_payload(
&d_issuer, &d_audience, &d_subject, &d_origin,
&d_capabilities, &d_resource_scope, &d_nonce,
&d_trace_id, d_issued_at, d_expires_at,
);
assert_eq!(bytes, re_encoded);
}
}