1use base64::Engine as _;
26use base64::engine::general_purpose::URL_SAFE_NO_PAD;
27use serde::{Deserialize, Serialize};
28
29use crate::principal::{PrincipalId, PrincipalKind};
30
31#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33pub enum DelegationError {
34 #[error("bound_to must be a user principal; got {0}")]
36 BoundToNotUser(PrincipalKind),
37 #[error("camp_id must be non-empty")]
39 EmptyCampId,
40 #[error("expires_at must be strictly greater than issued_at")]
42 ExpiresBeforeIssued,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55#[non_exhaustive]
56pub struct UserDelegation {
57 pub bound_to: PrincipalId,
60 pub camp_id: String,
63 pub issued_at: i64,
65 pub expires_at: i64,
67 #[serde(with = "ed25519_public_key_serde")]
70 pub user_signing_key: [u8; 32],
71 #[serde(with = "ed25519_signature_serde")]
74 pub signature: [u8; 64],
75}
76
77impl UserDelegation {
78 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 pub fn is_expired_at(&self, now: i64) -> bool {
111 self.expires_at <= now
112 }
113
114 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#[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#[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 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 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 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 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 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 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}