Skip to main content

cheers_core/
delegation.rs

1//! [`UserDelegation`] — the user-signed authorization that lets cheers bind a
2//! camp principal to a user at provision time.
3//!
4//! See `.yah/docs/working/mcp-auth-and-ownership.md` §Camp bootstrap. Yubaba
5//! provisions a camp on behalf of a user `U`; cheers won't allocate the camp
6//! principal until it sees a payload signed by `U` authorising the binding.
7//! The signing flow itself is yah-side (W122 QR-pair / mobile-app); cheers's
8//! job is to (a) carry a well-typed shape on the wire and (b) verify the
9//! Ed25519 signature inside `cheers-server` against a pubkey trusted for `U`.
10//!
11//! This module only defines the **shape** + the **canonical signing payload**.
12//! The verification primitive (Ed25519 over `signing_payload()`) lives in
13//! `cheers-server` so `cheers-core` stays crypto-free.
14//!
15//! ## Wire format
16//!
17//! `user_signing_key` is the 32-byte Ed25519 public key the signature must
18//! verify under; both it and the 64-byte `signature` ride on the wire as
19//! base64url-no-pad strings (matches the JWKS / service-principal-key encoding
20//! the doc uses elsewhere). `bound_to` MUST be a user principal — invariant is
21//! checked by [`UserDelegation::new`] *and* the [`Deserialize`] impl up front
22//! so a misconfigured (or hand-crafted wire) payload never reaches the
23//! authority.
24
25use base64::Engine as _;
26use base64::engine::general_purpose::URL_SAFE_NO_PAD;
27use serde::{Deserialize, Serialize};
28
29use crate::principal::{PrincipalId, PrincipalKind};
30
31/// Why a [`UserDelegation`] failed to validate before reaching the authority.
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33pub enum DelegationError {
34    /// `bound_to` must name a user principal (`user:<id>`).
35    #[error("bound_to must be a user principal; got {0}")]
36    BoundToNotUser(PrincipalKind),
37    /// `camp_id` was empty — the delegation is meaningless without a target.
38    #[error("camp_id must be non-empty")]
39    EmptyCampId,
40    /// `expires_at <= issued_at`.
41    #[error("expires_at must be strictly greater than issued_at")]
42    ExpiresBeforeIssued,
43}
44
45/// A short-lived authorization signed by a user `U` that lets cheers bind a
46/// camp principal to `U` at provision time.
47///
48/// The signed payload is the canonical byte serialization of every field
49/// except `signature` itself (see [`signing_payload`](Self::signing_payload)).
50/// Construct via [`UserDelegation::new`] — the constructor enforces the
51/// invariants the authority would otherwise reject downstream. Deserialization
52/// runs those same checks via [`RawUserDelegation`], so a wire payload can't
53/// bypass them.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55#[non_exhaustive]
56pub struct UserDelegation {
57    /// The user authorising the delegation. MUST have
58    /// [`kind = User`](PrincipalKind::User).
59    pub bound_to: PrincipalId,
60    /// Camp identifier (the bare half of the to-be-minted `camp:<id>`).
61    /// Non-empty.
62    pub camp_id: String,
63    /// Unix-second timestamp the user signed at.
64    pub issued_at: i64,
65    /// Unix-second timestamp the delegation stops being acceptable.
66    pub expires_at: i64,
67    /// Ed25519 public key the signature must verify under (32 bytes).
68    /// Wire form: base64url-no-pad string.
69    #[serde(with = "ed25519_public_key_serde")]
70    pub user_signing_key: [u8; 32],
71    /// Ed25519 signature over [`signing_payload`](Self::signing_payload)
72    /// (64 bytes). Wire form: base64url-no-pad string.
73    #[serde(with = "ed25519_signature_serde")]
74    pub signature: [u8; 64],
75}
76
77impl UserDelegation {
78    /// Construct + validate. Rejects a non-user `bound_to`, an empty
79    /// `camp_id`, or an `expires_at` not strictly after `issued_at`.
80    pub fn new(
81        bound_to: PrincipalId,
82        camp_id: impl Into<String>,
83        issued_at: i64,
84        expires_at: i64,
85        user_signing_key: [u8; 32],
86        signature: [u8; 64],
87    ) -> Result<Self, DelegationError> {
88        if bound_to.kind != PrincipalKind::User {
89            return Err(DelegationError::BoundToNotUser(bound_to.kind));
90        }
91        let camp_id = camp_id.into();
92        if camp_id.is_empty() {
93            return Err(DelegationError::EmptyCampId);
94        }
95        if expires_at <= issued_at {
96            return Err(DelegationError::ExpiresBeforeIssued);
97        }
98        Ok(Self {
99            bound_to,
100            camp_id,
101            issued_at,
102            expires_at,
103            user_signing_key,
104            signature,
105        })
106    }
107
108    /// `true` iff `expires_at <= now` — mirrors
109    /// [`McpClaims::is_expired_at`](crate::McpClaims::is_expired_at).
110    pub fn is_expired_at(&self, now: i64) -> bool {
111        self.expires_at <= now
112    }
113
114    /// Canonical bytes the user signed.
115    ///
116    /// Stable across runs: a fixed-ordered struct of every field except the
117    /// signature itself, serialized through `serde_json` (which preserves
118    /// struct field order). Producers (the yah-side W122 signing flow) and
119    /// verifiers (cheers-server) MUST agree on this format byte-for-byte;
120    /// any change is a wire-contract change.
121    pub fn signing_payload(&self) -> Vec<u8> {
122        let unsigned = UnsignedPayload {
123            bound_to: &self.bound_to,
124            camp_id: &self.camp_id,
125            issued_at: self.issued_at,
126            expires_at: self.expires_at,
127            user_signing_key: &self.user_signing_key,
128        };
129        serde_json::to_vec(&unsigned).expect("UnsignedPayload serializes infallibly")
130    }
131}
132
133/// The wire shape [`UserDelegation`] deserializes *through* — a structural
134/// mirror with the same fields + serde attrs, but no invariant checks. The
135/// [`Deserialize`] impl below reconstructs through [`UserDelegation::new`] so a
136/// hand-crafted payload can't bypass the constructor's guarantees (mirrors
137/// [`Principal`](crate::Principal) / `RawPrincipal`).
138#[derive(Deserialize)]
139struct RawUserDelegation {
140    bound_to: PrincipalId,
141    camp_id: String,
142    issued_at: i64,
143    expires_at: i64,
144    #[serde(with = "ed25519_public_key_serde")]
145    user_signing_key: [u8; 32],
146    #[serde(with = "ed25519_signature_serde")]
147    signature: [u8; 64],
148}
149
150impl<'de> Deserialize<'de> for UserDelegation {
151    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
152        let raw = RawUserDelegation::deserialize(de)?;
153        UserDelegation::new(
154            raw.bound_to,
155            raw.camp_id,
156            raw.issued_at,
157            raw.expires_at,
158            raw.user_signing_key,
159            raw.signature,
160        )
161        .map_err(serde::de::Error::custom)
162    }
163}
164
165/// The struct whose JSON encoding IS the canonical signing payload.
166///
167/// Field order here is load-bearing — `serde_json` emits keys in struct
168/// declaration order, and that's what both producer and verifier hash over.
169/// The signing key rides as base64url-no-pad for the same reason it does in
170/// [`UserDelegation`]: cross-platform Ed25519 toolchains all share that
171/// encoding.
172#[derive(Serialize)]
173struct UnsignedPayload<'a> {
174    bound_to: &'a PrincipalId,
175    camp_id: &'a str,
176    issued_at: i64,
177    expires_at: i64,
178    #[serde(with = "ed25519_public_key_serde_ref")]
179    user_signing_key: &'a [u8; 32],
180}
181
182mod ed25519_public_key_serde {
183    use super::*;
184    use serde::de::Error as DeError;
185
186    pub fn serialize<S: serde::Serializer>(bytes: &[u8; 32], ser: S) -> Result<S::Ok, S::Error> {
187        ser.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
188    }
189
190    pub fn deserialize<'de, D: serde::Deserializer<'de>>(de: D) -> Result<[u8; 32], D::Error> {
191        let s = String::deserialize(de)?;
192        let raw = URL_SAFE_NO_PAD
193            .decode(s.as_bytes())
194            .map_err(|e| D::Error::custom(format!("invalid base64url user_signing_key: {e}")))?;
195        raw.try_into().map_err(|v: Vec<u8>| {
196            D::Error::custom(format!("expected 32 user_signing_key bytes, got {}", v.len()))
197        })
198    }
199}
200
201mod ed25519_public_key_serde_ref {
202    use super::*;
203
204    pub fn serialize<S: serde::Serializer>(
205        bytes: &&[u8; 32],
206        ser: S,
207    ) -> Result<S::Ok, S::Error> {
208        ser.serialize_str(&URL_SAFE_NO_PAD.encode(**bytes))
209    }
210}
211
212mod ed25519_signature_serde {
213    use super::*;
214    use serde::de::Error as DeError;
215
216    pub fn serialize<S: serde::Serializer>(bytes: &[u8; 64], ser: S) -> Result<S::Ok, S::Error> {
217        ser.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
218    }
219
220    pub fn deserialize<'de, D: serde::Deserializer<'de>>(de: D) -> Result<[u8; 64], D::Error> {
221        let s = String::deserialize(de)?;
222        let raw = URL_SAFE_NO_PAD
223            .decode(s.as_bytes())
224            .map_err(|e| D::Error::custom(format!("invalid base64url signature: {e}")))?;
225        raw.try_into().map_err(|v: Vec<u8>| {
226            D::Error::custom(format!("expected 64 signature bytes, got {}", v.len()))
227        })
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn sample(now: i64) -> UserDelegation {
236        UserDelegation::new(
237            PrincipalId::user("alice"),
238            "camp-xyz",
239            now,
240            now + 600,
241            [7u8; 32],
242            [9u8; 64],
243        )
244        .unwrap()
245    }
246
247    #[test]
248    fn new_rejects_non_user_bound_to() {
249        let err = UserDelegation::new(
250            PrincipalId::service("yubaba"),
251            "c-1",
252            1_000,
253            1_600,
254            [0u8; 32],
255            [0u8; 64],
256        )
257        .unwrap_err();
258        assert_eq!(err, DelegationError::BoundToNotUser(PrincipalKind::Service));
259
260        let err = UserDelegation::new(
261            PrincipalId::camp("c-1"),
262            "c-1",
263            1_000,
264            1_600,
265            [0u8; 32],
266            [0u8; 64],
267        )
268        .unwrap_err();
269        assert_eq!(err, DelegationError::BoundToNotUser(PrincipalKind::Camp));
270    }
271
272    #[test]
273    fn new_rejects_empty_camp_id() {
274        let err = UserDelegation::new(
275            PrincipalId::user("alice"),
276            "",
277            1_000,
278            1_600,
279            [0u8; 32],
280            [0u8; 64],
281        )
282        .unwrap_err();
283        assert_eq!(err, DelegationError::EmptyCampId);
284    }
285
286    #[test]
287    fn new_rejects_expires_at_or_before_issued_at() {
288        let err = UserDelegation::new(
289            PrincipalId::user("alice"),
290            "c-1",
291            1_000,
292            1_000,
293            [0u8; 32],
294            [0u8; 64],
295        )
296        .unwrap_err();
297        assert_eq!(err, DelegationError::ExpiresBeforeIssued);
298
299        let err = UserDelegation::new(
300            PrincipalId::user("alice"),
301            "c-1",
302            1_000,
303            999,
304            [0u8; 32],
305            [0u8; 64],
306        )
307        .unwrap_err();
308        assert_eq!(err, DelegationError::ExpiresBeforeIssued);
309    }
310
311    #[test]
312    fn is_expired_at_uses_inclusive_boundary() {
313        let d = sample(1_000);
314        assert!(!d.is_expired_at(1_599));
315        assert!(d.is_expired_at(1_600));
316        assert!(d.is_expired_at(1_601));
317    }
318
319    #[test]
320    fn serde_roundtrips_with_base64url_keys_and_sig() {
321        let d = sample(1_000);
322        let json = serde_json::to_string(&d).unwrap();
323        // pubkey and signature ride as plain strings, not byte arrays.
324        assert!(json.contains("\"user_signing_key\":\""));
325        assert!(json.contains("\"signature\":\""));
326        assert!(!json.contains("[7,7"), "must NOT be a byte array: {json}");
327        let back: UserDelegation = serde_json::from_str(&json).unwrap();
328        assert_eq!(back, d);
329    }
330
331    #[test]
332    fn deserialize_rejects_wrong_length_pubkey() {
333        let json = r#"{
334            "bound_to":"user:alice",
335            "camp_id":"c-1",
336            "issued_at":1,
337            "expires_at":2,
338            "user_signing_key":"AAAA",
339            "signature":"AA"
340        }"#;
341        let err = serde_json::from_str::<UserDelegation>(json).unwrap_err();
342        assert!(
343            err.to_string().contains("32 user_signing_key bytes"),
344            "got {err}"
345        );
346    }
347
348    #[test]
349    fn deserialize_rejects_non_user_bound_to() {
350        // A wire delegation whose `bound_to` names a non-user principal must
351        // fail to deserialize — the raw intermediate can't bypass `new()`.
352        let json = serde_json::to_string(&sample(1_000))
353            .unwrap()
354            .replace("user:alice", "svc:yubaba");
355        let err = serde_json::from_str::<UserDelegation>(&json).unwrap_err();
356        assert!(
357            err.to_string().contains("bound_to must be a user principal"),
358            "got {err}"
359        );
360    }
361
362    #[test]
363    fn deserialize_rejects_expires_at_or_before_issued_at() {
364        // sample(1_000) has issued_at:1000, expires_at:1600. Collapse expiry to
365        // equal issued_at on the wire and the deserializer must reject it.
366        let json = serde_json::to_string(&sample(1_000))
367            .unwrap()
368            .replace("\"expires_at\":1600", "\"expires_at\":1000");
369        let err = serde_json::from_str::<UserDelegation>(&json).unwrap_err();
370        assert!(
371            err.to_string()
372                .contains("expires_at must be strictly greater than issued_at"),
373            "got {err}"
374        );
375    }
376
377    #[test]
378    fn signing_payload_is_stable_byte_order() {
379        // Two delegations with identical content produce byte-identical
380        // payloads — the property the producer side relies on.
381        let a = sample(1_000);
382        let b = sample(1_000);
383        assert_eq!(a.signing_payload(), b.signing_payload());
384    }
385
386    #[test]
387    fn signing_payload_excludes_signature() {
388        // Mutating only the signature must not change the payload — the
389        // verifier needs the payload to be a *function of the to-be-signed
390        // fields only*, not a circular dependency on the signature itself.
391        let a = sample(1_000);
392        let mut b = a.clone();
393        b.signature = [42u8; 64];
394        assert_eq!(a.signing_payload(), b.signing_payload());
395    }
396
397    #[test]
398    fn signing_payload_differs_when_any_signed_field_changes() {
399        let base = sample(1_000);
400        for mutate in &[
401            |d: &mut UserDelegation| d.camp_id = "other".into(),
402            |d: &mut UserDelegation| d.issued_at = 9_999,
403            |d: &mut UserDelegation| d.expires_at = 9_999,
404            |d: &mut UserDelegation| d.user_signing_key = [1u8; 32],
405            |d: &mut UserDelegation| d.bound_to = PrincipalId::user("bob"),
406        ] {
407            let mut m = base.clone();
408            mutate(&mut m);
409            assert_ne!(
410                base.signing_payload(),
411                m.signing_payload(),
412                "mutation must change the payload"
413            );
414        }
415    }
416
417    #[test]
418    fn signing_payload_starts_with_bound_to_field() {
419        // Pin the canonical-byte-order property: bound_to is the first field
420        // in the struct, so the encoded payload starts with "{"bound_to":".
421        // If someone reorders the fields, this test will fail loudly.
422        let d = sample(1_000);
423        let payload = d.signing_payload();
424        let head = std::str::from_utf8(&payload[..18]).unwrap();
425        assert_eq!(head, "{\"bound_to\":\"user:");
426    }
427}