Skip to main content

atd_runtime/ucan/
error.rs

1//! UCAN-lite parse / verify errors.
2//!
3//! Parse-stage errors (Phase B.1) all map to the wire-level
4//! `ERR_UCAN_INVALID = 1010` constant in `atd-protocol`. Verify-stage
5//! errors (Phase B.2) split across `ERR_UCAN_INVALID` (structural /
6//! signature), `ERR_UCAN_EXPIRED` (1011), `ERR_DELEGATION_TOO_DEEP`
7//! (1012), `ERR_AUDIENCE_MISMATCH` (1013).
8//!
9//! Spec: `docs/archive/superpowers/specs/2026-05-11-sp-capability-v2-design.md` §4.1 + §5.4
10
11use thiserror::Error;
12
13/// Errors returned by [`crate::ucan::parse_jwt`].
14///
15/// All variants map to wire code `ERR_UCAN_INVALID = 1010` —
16/// `retryable: false` (deterministic; same token → same failure).
17#[derive(Debug, Clone, PartialEq, Eq, Error)]
18pub enum UcanParseError {
19    /// JWT compact form requires exactly 3 `.`-separated segments.
20    #[error("malformed JWT: expected 3 segments, got {0}")]
21    MalformedJwt(usize),
22
23    /// Header or payload base64url-decode failed.
24    #[error("base64url decode failed in {segment}: {reason}")]
25    Base64Decode {
26        segment: &'static str,
27        reason: String,
28    },
29
30    /// Header or payload JSON deserialize failed.
31    #[error("JSON parse failed in {segment}: {reason}")]
32    JsonParse {
33        segment: &'static str,
34        reason: String,
35    },
36
37    /// `header.alg != "EdDSA"`. Spec §4.3.
38    #[error("unsupported alg: expected EdDSA, got {0:?}")]
39    UnsupportedAlg(String),
40
41    /// `header.typ != "ucan/1.0+jwt"`. Spec §4.1.
42    #[error("unsupported typ: expected ucan/1.0+jwt, got {0:?}")]
43    UnsupportedTyp(String),
44
45    /// `header.ucv != "1.0"`. Spec §4.1.
46    #[error("unsupported ucv: expected 1.0, got {0:?}")]
47    UnsupportedUcv(String),
48
49    /// `payload.cmd != "atd-cap"`. Cross-system replay prevention,
50    /// spec §4.5.
51    #[error("non-atd-cap UCAN: expected cmd=\"atd-cap\", got {0:?}")]
52    NonAtdCap(String),
53
54    /// `iss` or `aud` doesn't start with `did:key:z`. Spec §4.4 —
55    /// `did:web` / `did:agent` are deferred to follow-up SPs.
56    #[error("unsupported DID method in {field}: {did:?} (only did:key:z... accepted)")]
57    UnsupportedDidMethod { field: &'static str, did: String },
58}
59
60// Convenience aliases for the parser to use `.map_err(|e| ...)`.
61impl UcanParseError {
62    pub(crate) fn base64(segment: &'static str, e: impl std::fmt::Display) -> Self {
63        Self::Base64Decode {
64            segment,
65            reason: e.to_string(),
66        }
67    }
68
69    pub(crate) fn json(segment: &'static str, e: impl std::fmt::Display) -> Self {
70        Self::JsonParse {
71            segment,
72            reason: e.to_string(),
73        }
74    }
75}
76
77/// Errors returned by [`crate::ucan::verify_jwt`] / `verify_tokens`.
78///
79/// Parse-stage failures bubble up as `Parse(_)` → wire code
80/// `ERR_UCAN_INVALID = 1010`. Other verify-stage failures map to:
81///
82/// | Variant | Wire code |
83/// |---|---|
84/// | `Parse`, `BadSignature`, `WideningAttenuation`, `MultiParentNotSupported`, `MalformedDidKey`, `MalformedSignature`, `ChainBroken` | 1010 ERR_UCAN_INVALID |
85/// | `Expired` | 1011 ERR_UCAN_EXPIRED |
86/// | `ChainTooDeep` | 1012 ERR_DELEGATION_TOO_DEEP |
87/// | `AudienceMismatch` | 1013 ERR_AUDIENCE_MISMATCH |
88/// | `Revoked` | 1010 ERR_UCAN_INVALID (with revoked-cid hint) |
89///
90/// All `retryable: false` (deterministic).
91#[derive(Debug, Error)]
92pub enum UcanVerifyError {
93    /// Underlying parse failure (any [`UcanParseError`] variant).
94    #[error("UCAN parse error: {0}")]
95    Parse(#[from] UcanParseError),
96
97    /// Ed25519 signature verification failed for the named token CID.
98    #[error("Ed25519 signature verification failed for token {cid}")]
99    BadSignature { cid: String },
100
101    /// Signature segment failed to base64url-decode or had wrong length.
102    #[error("malformed signature on token {cid}: {reason}")]
103    MalformedSignature { cid: String, reason: String },
104
105    /// `did:key:z...` failed to decode (bad multibase, wrong multicodec
106    /// prefix, or wrong key length).
107    #[error("malformed did:key in {field}: {reason}")]
108    MalformedDidKey { field: &'static str, reason: String },
109
110    /// A link's `exp <= now()`. Distinct from `Parse` because the token
111    /// was well-formed — it just lapsed.
112    #[error("UCAN expired at link {cid}: exp={exp}, now={now}")]
113    Expired { cid: String, exp: i64, now: i64 },
114
115    /// Child claims caps the parent did not grant.
116    #[error("attenuation widening at link {cid}: parent={parent:?}, child={child:?}")]
117    WideningAttenuation {
118        cid: String,
119        parent: Vec<String>,
120        child: Vec<String>,
121    },
122
123    /// Link N's `iss` doesn't match link N-1's `aud`. The chain is broken.
124    #[error(
125        "chain broken between {parent_cid} (aud={parent_aud}) and {child_cid} (iss={child_iss})"
126    )]
127    ChainBroken {
128        parent_cid: String,
129        parent_aud: String,
130        child_cid: String,
131        child_iss: String,
132    },
133
134    /// The leaf's `aud` doesn't match the configured expected audience.
135    #[error("audience mismatch: leaf.aud={leaf_aud}, expected={expected}")]
136    AudienceMismatch { leaf_aud: String, expected: String },
137
138    /// The chain exceeds the configured `max_chain_depth`.
139    #[error("chain too deep: depth={depth}, max={max}")]
140    ChainTooDeep { depth: u8, max: u8 },
141
142    /// A link's CID was in the revocation store.
143    #[error("UCAN revoked: cid={cid}")]
144    Revoked { cid: String },
145
146    /// `prf` field has more than one parent. UCAN v1.0 spec allows
147    /// multi-parent; SP-capability-v2 v1 supports single-chain only.
148    #[error("multi-parent UCAN not supported in v1 (link {cid} has {n_parents} parents)")]
149    MultiParentNotSupported { cid: String, n_parents: usize },
150}
151
152/// Wire-code mapping per [`UcanVerifyError`] variant.
153///
154/// Returns `(code, retryable)` for use by dispatch when converting a
155/// verify error into a `Response::Error`. All verify errors are
156/// non-retryable (deterministic on the same input).
157pub fn wire_code(err: &UcanVerifyError) -> u16 {
158    use atd_protocol::{
159        ERR_AUDIENCE_MISMATCH, ERR_DELEGATION_TOO_DEEP, ERR_UCAN_EXPIRED, ERR_UCAN_INVALID,
160    };
161    match err {
162        UcanVerifyError::Expired { .. } => ERR_UCAN_EXPIRED,
163        UcanVerifyError::ChainTooDeep { .. } => ERR_DELEGATION_TOO_DEEP,
164        UcanVerifyError::AudienceMismatch { .. } => ERR_AUDIENCE_MISMATCH,
165        _ => ERR_UCAN_INVALID,
166    }
167}