Skip to main content

joy_core/auth/
token.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! AI delegation tokens with dual signatures (ADR-023, refined by ADR-033).
5//!
6//! Each token carries two Ed25519 signatures:
7//! 1. Delegator signature (human's identity key) — proves authorization
8//! 2. Binding signature (stable delegation key per (human, AI)) — binds to
9//!    the public key recorded in `project.yaml` under
10//!    `members[<human>].ai_delegations[<ai-member>].delegation_key`.
11//!
12//! Tokens are passed via `--token` flag or `JOY_TOKEN` env var to `joy auth`.
13
14use chrono::{DateTime, Duration, Utc};
15use serde::{Deserialize, Serialize};
16
17use super::sign::{IdentityKeypair, PublicKey};
18use crate::error::JoyError;
19
20/// Token prefix for visual identification.
21const TOKEN_PREFIX: &str = "joy_t_";
22
23/// Claims encoded in a delegation token.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct DelegationClaims {
26    /// Unique identifier for this specific token (UUID v4). Used to detect
27    /// replay: once a token has been redeemed, subsequent redemption
28    /// attempts for the same `token_id` are rejected (ADR-033).
29    pub token_id: String,
30    pub ai_member: String,
31    pub delegated_by: String,
32    pub project_id: String,
33    pub created: DateTime<Utc>,
34    pub expires: Option<DateTime<Utc>>,
35}
36
37/// A delegation token with dual signatures.
38#[derive(Debug, Serialize, Deserialize)]
39pub struct DelegationToken {
40    pub claims: DelegationClaims,
41    /// Hex-encoded Ed25519 signature by the delegating human's key.
42    pub delegator_signature: String,
43    /// Hex-encoded Ed25519 signature by the stable delegation key.
44    pub binding_signature: String,
45    /// Hex-encoded public key of the delegation keypair. Redundant with the
46    /// value recorded in `project.yaml` under `ai_delegations`; kept as an
47    /// aid for debugging and for error messages pointing at a mismatch.
48    pub delegation_public_key: String,
49}
50
51/// Create a delegation token with dual signatures.
52///
53/// The caller supplies the human's identity keypair (delegator, authorises
54/// issuance via the first signature) and the stable per-(human, AI)
55/// delegation keypair (produces the binding signature). The matching
56/// `delegation_public_key` must already be recorded in `project.yaml`.
57pub fn create_token(
58    delegator_keypair: &IdentityKeypair,
59    delegation_keypair: &IdentityKeypair,
60    ai_member: &str,
61    human: &str,
62    project_id: &str,
63    ttl: Option<Duration>,
64) -> DelegationToken {
65    let now = Utc::now();
66    let claims = DelegationClaims {
67        token_id: uuid::Uuid::new_v4().to_string(),
68        ai_member: ai_member.to_string(),
69        delegated_by: human.to_string(),
70        project_id: project_id.to_string(),
71        created: now,
72        expires: ttl.map(|d| now + d),
73    };
74    let claims_json = serde_json::to_string(&claims).expect("claims serialize");
75
76    let delegator_sig = delegator_keypair.sign(claims_json.as_bytes());
77    let binding_sig = delegation_keypair.sign(claims_json.as_bytes());
78
79    DelegationToken {
80        claims,
81        delegator_signature: hex::encode(delegator_sig),
82        binding_signature: hex::encode(binding_sig),
83        delegation_public_key: delegation_keypair.public_key().to_hex(),
84    }
85}
86
87/// Validate a delegation token against the delegator's identity key and the
88/// stable delegation key recorded in `project.yaml`.
89pub fn validate_token(
90    token: &DelegationToken,
91    delegator_pk: &PublicKey,
92    delegation_pk: &PublicKey,
93    project_id: &str,
94) -> Result<DelegationClaims, JoyError> {
95    if token.claims.project_id != project_id {
96        return Err(JoyError::AuthFailed(
97            "token belongs to a different project".into(),
98        ));
99    }
100
101    if let Some(expires) = token.claims.expires {
102        if Utc::now() > expires {
103            return Err(JoyError::AuthFailed(format!(
104                "Token expired (issued {}, expired {}). \
105                 Ask the human to issue a new one with: joy auth token add {}",
106                token.claims.created.format("%Y-%m-%d %H:%M UTC"),
107                expires.format("%Y-%m-%d %H:%M UTC"),
108                token.claims.ai_member
109            )));
110        }
111    }
112
113    let claims_json = serde_json::to_string(&token.claims).expect("claims serialize");
114
115    let delegator_sig = hex::decode(&token.delegator_signature)
116        .map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
117    delegator_pk.verify(claims_json.as_bytes(), &delegator_sig)?;
118
119    let binding_sig =
120        hex::decode(&token.binding_signature).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
121    delegation_pk.verify(claims_json.as_bytes(), &binding_sig)?;
122
123    Ok(token.claims.clone())
124}
125
126/// Encode a token as a portable string (`joy_t_<base64>`).
127pub fn encode_token(token: &DelegationToken) -> String {
128    let json = serde_json::to_string(token).expect("token serialize");
129    let encoded = base64_encode(json.as_bytes());
130    format!("{TOKEN_PREFIX}{encoded}")
131}
132
133/// Decode a token from its portable string representation.
134pub fn decode_token(s: &str) -> Result<DelegationToken, JoyError> {
135    let data = s.strip_prefix(TOKEN_PREFIX).ok_or_else(|| {
136        JoyError::AuthFailed("invalid token format (missing joy_t_ prefix)".into())
137    })?;
138    let json = base64_decode(data)?;
139    let token: DelegationToken = serde_json::from_slice(&json)
140        .map_err(|e| JoyError::AuthFailed(format!("invalid token: {e}")))?;
141    Ok(token)
142}
143
144/// Check if a string looks like a delegation token (has the `joy_t_` prefix).
145pub fn is_token(s: &str) -> bool {
146    s.starts_with(TOKEN_PREFIX)
147}
148
149fn base64_encode(data: &[u8]) -> String {
150    use base64ct::{Base64, Encoding};
151    Base64::encode_string(data)
152}
153
154fn base64_decode(s: &str) -> Result<Vec<u8>, JoyError> {
155    use base64ct::{Base64, Encoding};
156    Base64::decode_vec(s).map_err(|e| JoyError::AuthFailed(format!("base64 decode: {e}")))
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::auth::{derive, sign};
163    use chrono::Duration;
164
165    const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
166
167    fn test_keypair() -> (sign::IdentityKeypair, sign::PublicKey) {
168        let salt = derive::Salt::from_hex(
169            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
170        )
171        .unwrap();
172        let key = derive::derive_key(TEST_PASSPHRASE, &salt).unwrap();
173        let kp = sign::IdentityKeypair::from_derived_key(&key);
174        let pk = kp.public_key();
175        (kp, pk)
176    }
177
178    fn fresh_delegation() -> (sign::IdentityKeypair, sign::PublicKey) {
179        let kp = sign::IdentityKeypair::from_random();
180        let pk = kp.public_key();
181        (kp, pk)
182    }
183
184    #[test]
185    fn create_and_validate_token() {
186        let (delegator, delegator_pk) = test_keypair();
187        let (delegation, delegation_pk) = fresh_delegation();
188        let token = create_token(
189            &delegator,
190            &delegation,
191            "ai:claude@joy",
192            "human@example.com",
193            "TST",
194            None,
195        );
196        let claims = validate_token(&token, &delegator_pk, &delegation_pk, "TST").unwrap();
197        assert_eq!(claims.ai_member, "ai:claude@joy");
198        assert_eq!(claims.delegated_by, "human@example.com");
199        assert_eq!(token.delegation_public_key, delegation_pk.to_hex());
200    }
201
202    #[test]
203    fn token_with_expiry() {
204        let (delegator, delegator_pk) = test_keypair();
205        let (delegation, delegation_pk) = fresh_delegation();
206        let token = create_token(
207            &delegator,
208            &delegation,
209            "ai:claude@joy",
210            "human@example.com",
211            "TST",
212            Some(Duration::hours(8)),
213        );
214        let claims = validate_token(&token, &delegator_pk, &delegation_pk, "TST").unwrap();
215        assert!(claims.expires.is_some());
216    }
217
218    #[test]
219    fn expired_token_rejected() {
220        let (delegator, delegator_pk) = test_keypair();
221        let (delegation, delegation_pk) = fresh_delegation();
222        let token = create_token(
223            &delegator,
224            &delegation,
225            "ai:claude@joy",
226            "human@example.com",
227            "TST",
228            Some(Duration::seconds(-1)),
229        );
230        assert!(validate_token(&token, &delegator_pk, &delegation_pk, "TST").is_err());
231    }
232
233    #[test]
234    fn wrong_project_rejected() {
235        let (delegator, delegator_pk) = test_keypair();
236        let (delegation, delegation_pk) = fresh_delegation();
237        let token = create_token(
238            &delegator,
239            &delegation,
240            "ai:claude@joy",
241            "human@example.com",
242            "TST",
243            None,
244        );
245        assert!(validate_token(&token, &delegator_pk, &delegation_pk, "OTHER").is_err());
246    }
247
248    #[test]
249    fn tampered_claims_rejected() {
250        let (delegator, delegator_pk) = test_keypair();
251        let (delegation, delegation_pk) = fresh_delegation();
252        let mut token = create_token(
253            &delegator,
254            &delegation,
255            "ai:claude@joy",
256            "human@example.com",
257            "TST",
258            None,
259        );
260        token.claims.ai_member = "ai:attacker@evil".into();
261        assert!(validate_token(&token, &delegator_pk, &delegation_pk, "TST").is_err());
262    }
263
264    #[test]
265    fn wrong_delegator_key_rejected() {
266        let (delegator, _) = test_keypair();
267        let (delegation, delegation_pk) = fresh_delegation();
268        let token = create_token(
269            &delegator,
270            &delegation,
271            "ai:claude@joy",
272            "human@example.com",
273            "TST",
274            None,
275        );
276
277        let other_salt = derive::generate_salt();
278        let other_key =
279            derive::derive_key("alpha bravo charlie delta echo foxtrot", &other_salt).unwrap();
280        let other_kp = sign::IdentityKeypair::from_derived_key(&other_key);
281        let other_pk = other_kp.public_key();
282
283        assert!(validate_token(&token, &other_pk, &delegation_pk, "TST").is_err());
284    }
285
286    #[test]
287    fn wrong_delegation_key_rejected() {
288        let (delegator, delegator_pk) = test_keypair();
289        let (delegation, _) = fresh_delegation();
290        let token = create_token(
291            &delegator,
292            &delegation,
293            "ai:claude@joy",
294            "human@example.com",
295            "TST",
296            None,
297        );
298
299        // Simulates rotation: validator looks up a different delegation_key in project.yaml.
300        let (_, rotated_pk) = fresh_delegation();
301        assert!(validate_token(&token, &delegator_pk, &rotated_pk, "TST").is_err());
302    }
303
304    #[test]
305    fn encode_decode_roundtrip() {
306        let (delegator, delegator_pk) = test_keypair();
307        let (delegation, delegation_pk) = fresh_delegation();
308        let token = create_token(
309            &delegator,
310            &delegation,
311            "ai:claude@joy",
312            "human@example.com",
313            "TST",
314            None,
315        );
316        let encoded = encode_token(&token);
317        assert!(encoded.starts_with("joy_t_"));
318        let decoded = decode_token(&encoded).unwrap();
319        let claims = validate_token(&decoded, &delegator_pk, &delegation_pk, "TST").unwrap();
320        assert_eq!(claims.ai_member, "ai:claude@joy");
321    }
322
323    #[test]
324    fn invalid_prefix_rejected() {
325        assert!(decode_token("invalid_prefix_data").is_err());
326    }
327}