use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use crate::principal::{PrincipalId, PrincipalKind};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DelegationError {
#[error("bound_to must be a user principal; got {0}")]
BoundToNotUser(PrincipalKind),
#[error("camp_id must be non-empty")]
EmptyCampId,
#[error("expires_at must be strictly greater than issued_at")]
ExpiresBeforeIssued,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct UserDelegation {
pub bound_to: PrincipalId,
pub camp_id: String,
pub issued_at: i64,
pub expires_at: i64,
#[serde(with = "ed25519_public_key_serde")]
pub user_signing_key: [u8; 32],
#[serde(with = "ed25519_signature_serde")]
pub signature: [u8; 64],
}
impl UserDelegation {
pub fn new(
bound_to: PrincipalId,
camp_id: impl Into<String>,
issued_at: i64,
expires_at: i64,
user_signing_key: [u8; 32],
signature: [u8; 64],
) -> Result<Self, DelegationError> {
if bound_to.kind != PrincipalKind::User {
return Err(DelegationError::BoundToNotUser(bound_to.kind));
}
let camp_id = camp_id.into();
if camp_id.is_empty() {
return Err(DelegationError::EmptyCampId);
}
if expires_at <= issued_at {
return Err(DelegationError::ExpiresBeforeIssued);
}
Ok(Self {
bound_to,
camp_id,
issued_at,
expires_at,
user_signing_key,
signature,
})
}
pub fn is_expired_at(&self, now: i64) -> bool {
self.expires_at <= now
}
pub fn signing_payload(&self) -> Vec<u8> {
let unsigned = UnsignedPayload {
bound_to: &self.bound_to,
camp_id: &self.camp_id,
issued_at: self.issued_at,
expires_at: self.expires_at,
user_signing_key: &self.user_signing_key,
};
serde_json::to_vec(&unsigned).expect("UnsignedPayload serializes infallibly")
}
}
#[derive(Deserialize)]
struct RawUserDelegation {
bound_to: PrincipalId,
camp_id: String,
issued_at: i64,
expires_at: i64,
#[serde(with = "ed25519_public_key_serde")]
user_signing_key: [u8; 32],
#[serde(with = "ed25519_signature_serde")]
signature: [u8; 64],
}
impl<'de> Deserialize<'de> for UserDelegation {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let raw = RawUserDelegation::deserialize(de)?;
UserDelegation::new(
raw.bound_to,
raw.camp_id,
raw.issued_at,
raw.expires_at,
raw.user_signing_key,
raw.signature,
)
.map_err(serde::de::Error::custom)
}
}
#[derive(Serialize)]
struct UnsignedPayload<'a> {
bound_to: &'a PrincipalId,
camp_id: &'a str,
issued_at: i64,
expires_at: i64,
#[serde(with = "ed25519_public_key_serde_ref")]
user_signing_key: &'a [u8; 32],
}
mod ed25519_public_key_serde {
use super::*;
use serde::de::Error as DeError;
pub fn serialize<S: serde::Serializer>(bytes: &[u8; 32], ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(de: D) -> Result<[u8; 32], D::Error> {
let s = String::deserialize(de)?;
let raw = URL_SAFE_NO_PAD
.decode(s.as_bytes())
.map_err(|e| D::Error::custom(format!("invalid base64url user_signing_key: {e}")))?;
raw.try_into().map_err(|v: Vec<u8>| {
D::Error::custom(format!("expected 32 user_signing_key bytes, got {}", v.len()))
})
}
}
mod ed25519_public_key_serde_ref {
use super::*;
pub fn serialize<S: serde::Serializer>(
bytes: &&[u8; 32],
ser: S,
) -> Result<S::Ok, S::Error> {
ser.serialize_str(&URL_SAFE_NO_PAD.encode(**bytes))
}
}
mod ed25519_signature_serde {
use super::*;
use serde::de::Error as DeError;
pub fn serialize<S: serde::Serializer>(bytes: &[u8; 64], ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(de: D) -> Result<[u8; 64], D::Error> {
let s = String::deserialize(de)?;
let raw = URL_SAFE_NO_PAD
.decode(s.as_bytes())
.map_err(|e| D::Error::custom(format!("invalid base64url signature: {e}")))?;
raw.try_into().map_err(|v: Vec<u8>| {
D::Error::custom(format!("expected 64 signature bytes, got {}", v.len()))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(now: i64) -> UserDelegation {
UserDelegation::new(
PrincipalId::user("alice"),
"camp-xyz",
now,
now + 600,
[7u8; 32],
[9u8; 64],
)
.unwrap()
}
#[test]
fn new_rejects_non_user_bound_to() {
let err = UserDelegation::new(
PrincipalId::service("yubaba"),
"c-1",
1_000,
1_600,
[0u8; 32],
[0u8; 64],
)
.unwrap_err();
assert_eq!(err, DelegationError::BoundToNotUser(PrincipalKind::Service));
let err = UserDelegation::new(
PrincipalId::camp("c-1"),
"c-1",
1_000,
1_600,
[0u8; 32],
[0u8; 64],
)
.unwrap_err();
assert_eq!(err, DelegationError::BoundToNotUser(PrincipalKind::Camp));
}
#[test]
fn new_rejects_empty_camp_id() {
let err = UserDelegation::new(
PrincipalId::user("alice"),
"",
1_000,
1_600,
[0u8; 32],
[0u8; 64],
)
.unwrap_err();
assert_eq!(err, DelegationError::EmptyCampId);
}
#[test]
fn new_rejects_expires_at_or_before_issued_at() {
let err = UserDelegation::new(
PrincipalId::user("alice"),
"c-1",
1_000,
1_000,
[0u8; 32],
[0u8; 64],
)
.unwrap_err();
assert_eq!(err, DelegationError::ExpiresBeforeIssued);
let err = UserDelegation::new(
PrincipalId::user("alice"),
"c-1",
1_000,
999,
[0u8; 32],
[0u8; 64],
)
.unwrap_err();
assert_eq!(err, DelegationError::ExpiresBeforeIssued);
}
#[test]
fn is_expired_at_uses_inclusive_boundary() {
let d = sample(1_000);
assert!(!d.is_expired_at(1_599));
assert!(d.is_expired_at(1_600));
assert!(d.is_expired_at(1_601));
}
#[test]
fn serde_roundtrips_with_base64url_keys_and_sig() {
let d = sample(1_000);
let json = serde_json::to_string(&d).unwrap();
assert!(json.contains("\"user_signing_key\":\""));
assert!(json.contains("\"signature\":\""));
assert!(!json.contains("[7,7"), "must NOT be a byte array: {json}");
let back: UserDelegation = serde_json::from_str(&json).unwrap();
assert_eq!(back, d);
}
#[test]
fn deserialize_rejects_wrong_length_pubkey() {
let json = r#"{
"bound_to":"user:alice",
"camp_id":"c-1",
"issued_at":1,
"expires_at":2,
"user_signing_key":"AAAA",
"signature":"AA"
}"#;
let err = serde_json::from_str::<UserDelegation>(json).unwrap_err();
assert!(
err.to_string().contains("32 user_signing_key bytes"),
"got {err}"
);
}
#[test]
fn deserialize_rejects_non_user_bound_to() {
let json = serde_json::to_string(&sample(1_000))
.unwrap()
.replace("user:alice", "svc:yubaba");
let err = serde_json::from_str::<UserDelegation>(&json).unwrap_err();
assert!(
err.to_string().contains("bound_to must be a user principal"),
"got {err}"
);
}
#[test]
fn deserialize_rejects_expires_at_or_before_issued_at() {
let json = serde_json::to_string(&sample(1_000))
.unwrap()
.replace("\"expires_at\":1600", "\"expires_at\":1000");
let err = serde_json::from_str::<UserDelegation>(&json).unwrap_err();
assert!(
err.to_string()
.contains("expires_at must be strictly greater than issued_at"),
"got {err}"
);
}
#[test]
fn signing_payload_is_stable_byte_order() {
let a = sample(1_000);
let b = sample(1_000);
assert_eq!(a.signing_payload(), b.signing_payload());
}
#[test]
fn signing_payload_excludes_signature() {
let a = sample(1_000);
let mut b = a.clone();
b.signature = [42u8; 64];
assert_eq!(a.signing_payload(), b.signing_payload());
}
#[test]
fn signing_payload_differs_when_any_signed_field_changes() {
let base = sample(1_000);
for mutate in &[
|d: &mut UserDelegation| d.camp_id = "other".into(),
|d: &mut UserDelegation| d.issued_at = 9_999,
|d: &mut UserDelegation| d.expires_at = 9_999,
|d: &mut UserDelegation| d.user_signing_key = [1u8; 32],
|d: &mut UserDelegation| d.bound_to = PrincipalId::user("bob"),
] {
let mut m = base.clone();
mutate(&mut m);
assert_ne!(
base.signing_payload(),
m.signing_payload(),
"mutation must change the payload"
);
}
}
#[test]
fn signing_payload_starts_with_bound_to_field() {
let d = sample(1_000);
let payload = d.signing_payload();
let head = std::str::from_utf8(&payload[..18]).unwrap();
assert_eq!(head, "{\"bound_to\":\"user:");
}
}