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 and
5//! ADR-041).
6//!
7//! Each token carries two Ed25519 signatures:
8//! 1. Delegator signature (human's identity key) — proves authorization
9//! 2. Binding signature (stable delegation key per (human, AI)) — binds to
10//!    the public key recorded in `project.yaml` under
11//!    `members[<human>].ai_delegations[<ai-member>].delegation_verifier`.
12//!
13//! Tokens carry a `scopes` claim (ADR-041 §3). The default `["auth"]` lets
14//! the AI run joy commands as the AI member. With `--crypt` (`["auth",
15//! "crypt"]`) the token additionally embeds the delegation private key as
16//! a 32-byte Ed25519 seed so the AI can unwrap zone keys for the duration
17//! of the token's TTL.
18//!
19//! Tokens are passed via `--token` flag or `JOY_TOKEN` env var to `joy auth`.
20
21use chrono::{DateTime, Duration, Utc};
22use serde::{Deserialize, Serialize};
23
24use super::{IdentityKeypair, PublicKey};
25use crate::error::JoyError;
26
27/// Token prefix for visual identification.
28const TOKEN_PREFIX: &str = "joy_t_";
29
30/// Default scope set when a token's claims omit the field (back-compat).
31fn default_scopes() -> Vec<String> {
32    vec!["auth".to_string()]
33}
34
35/// Scope value indicating the token additionally carries the delegation
36/// private key for Crypt unwrap (ADR-041).
37pub const SCOPE_CRYPT: &str = "crypt";
38/// Scope value for ordinary AI command authentication (default).
39pub const SCOPE_AUTH: &str = "auth";
40
41/// Claims encoded in a delegation token.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct DelegationClaims {
44    /// Unique identifier for this specific token (UUID v4). Used to detect
45    /// replay: once a token has been redeemed, subsequent redemption
46    /// attempts for the same `token_id` are rejected (ADR-033).
47    pub token_id: String,
48    pub ai_member: String,
49    pub delegated_by: String,
50    pub project_id: String,
51    pub created: DateTime<Utc>,
52    pub expires: Option<DateTime<Utc>>,
53    /// Capability scopes for this token. Default `["auth"]`. Tokens issued
54    /// with `--crypt` also carry `"crypt"` (ADR-041 §3). Unknown scopes are
55    /// preserved so newer scope vocabularies do not break older verifiers.
56    #[serde(default = "default_scopes")]
57    pub scopes: Vec<String>,
58}
59
60impl DelegationClaims {
61    /// Whether the token authorises Crypt unwrap (carries delegation privkey).
62    pub fn has_crypt_scope(&self) -> bool {
63        self.scopes.iter().any(|s| s == SCOPE_CRYPT)
64    }
65}
66
67/// A delegation token with dual signatures.
68#[derive(Debug, Serialize, Deserialize)]
69pub struct DelegationToken {
70    pub claims: DelegationClaims,
71    /// Hex-encoded Ed25519 signature by the delegating human's key.
72    pub delegator_signature: String,
73    /// Hex-encoded Ed25519 signature by the stable delegation key.
74    pub binding_signature: String,
75    /// Hex-encoded public key of the delegation keypair. Redundant with the
76    /// value recorded in `project.yaml` under `ai_delegations`; kept as an
77    /// aid for debugging and for error messages pointing at a mismatch.
78    pub delegation_public_key: String,
79    /// Hex-encoded 32-byte Ed25519 seed for the delegation keypair. Present
80    /// only on tokens with `crypt` scope; lets the AI re-derive the
81    /// delegation keypair to unwrap zone keys (ADR-041 §2). Never persisted
82    /// on the AI's disk; it travels in the token string and lives in the
83    /// `JOY_SESSION` env var while a session is active.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub delegation_private_key: Option<String>,
86}
87
88/// Cryptographic material used to sign a delegation token.
89pub struct TokenSigningKeys<'a> {
90    /// Human's identity keypair, produces the delegator signature.
91    pub delegator: &'a IdentityKeypair,
92    /// Stable per-(human, AI) delegation keypair, produces the binding
93    /// signature. The matching public key must already be recorded in
94    /// `project.yaml`.
95    pub delegation: &'a IdentityKeypair,
96    /// 32-byte Ed25519 seed of the delegation keypair. Embedded in the
97    /// token wire format when [`TokenIssueParams::crypt_scope`] is true
98    /// (ADR-041 §3); otherwise discarded.
99    pub delegation_seed: &'a [u8; 32],
100}
101
102/// Identity and policy fields for a token issuance.
103pub struct TokenIssueParams<'a> {
104    pub ai_member: &'a str,
105    pub human: &'a str,
106    pub project_id: &'a str,
107    pub ttl: Option<Duration>,
108    /// Whether the token also carries the delegation private key for
109    /// Crypt unwrap (ADR-041 §3). When false the token is auth-only.
110    pub crypt_scope: bool,
111}
112
113/// Create a delegation token with dual signatures.
114///
115/// The caller supplies the signing material (delegator + delegation
116/// keypair, plus the delegation seed for Crypt-scope tokens) and the
117/// claim parameters. When `params.crypt_scope` is true, the delegation
118/// seed is embedded in the token so the AI can re-derive the keypair
119/// and unwrap zone keys; the scope claim becomes `["auth", "crypt"]`.
120/// When false, the seed is discarded and the token is auth-only.
121pub fn create_token(keys: TokenSigningKeys<'_>, params: TokenIssueParams<'_>) -> DelegationToken {
122    let now = Utc::now();
123    let scopes = if params.crypt_scope {
124        vec![SCOPE_AUTH.to_string(), SCOPE_CRYPT.to_string()]
125    } else {
126        vec![SCOPE_AUTH.to_string()]
127    };
128    let claims = DelegationClaims {
129        token_id: uuid::Uuid::new_v4().to_string(),
130        ai_member: params.ai_member.to_string(),
131        delegated_by: params.human.to_string(),
132        project_id: params.project_id.to_string(),
133        created: now,
134        expires: params.ttl.map(|d| now + d),
135        scopes,
136    };
137    let claims_json = serde_json::to_string(&claims).expect("claims serialize");
138
139    let delegator_sig = keys.delegator.sign(claims_json.as_bytes());
140    let binding_sig = keys.delegation.sign(claims_json.as_bytes());
141
142    let delegation_private_key = if params.crypt_scope {
143        Some(hex::encode(keys.delegation_seed))
144    } else {
145        None
146    };
147
148    DelegationToken {
149        claims,
150        delegator_signature: hex::encode(delegator_sig),
151        binding_signature: hex::encode(binding_sig),
152        delegation_public_key: keys.delegation.public_key().to_hex(),
153        delegation_private_key,
154    }
155}
156
157/// Validate a delegation token against the delegator's identity key and the
158/// stable delegation key recorded in `project.yaml`.
159pub fn validate_token(
160    token: &DelegationToken,
161    delegator_pk: &PublicKey,
162    delegation_pk: &PublicKey,
163    project_id: &str,
164) -> Result<DelegationClaims, JoyError> {
165    if token.claims.project_id != project_id {
166        return Err(JoyError::AuthFailed(
167            "token belongs to a different project".into(),
168        ));
169    }
170
171    if let Some(expires) = token.claims.expires {
172        if Utc::now() > expires {
173            return Err(JoyError::AuthFailed(format!(
174                "Token expired (issued {}, expired {}). \
175                 Ask the human to issue a new one with: joy auth token add {}",
176                token.claims.created.format("%Y-%m-%d %H:%M UTC"),
177                expires.format("%Y-%m-%d %H:%M UTC"),
178                token.claims.ai_member
179            )));
180        }
181    }
182
183    let claims_json = serde_json::to_string(&token.claims).expect("claims serialize");
184
185    let delegator_sig = hex::decode(&token.delegator_signature)
186        .map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
187    delegator_pk.verify(claims_json.as_bytes(), &delegator_sig)?;
188
189    let binding_sig =
190        hex::decode(&token.binding_signature).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
191    delegation_pk.verify(claims_json.as_bytes(), &binding_sig)?;
192
193    Ok(token.claims.clone())
194}
195
196/// Encode a token as a portable string (`joy_t_<base64>`).
197pub fn encode_token(token: &DelegationToken) -> String {
198    let json = serde_json::to_string(token).expect("token serialize");
199    let encoded = base64_encode(json.as_bytes());
200    format!("{TOKEN_PREFIX}{encoded}")
201}
202
203/// Decode a token from its portable string representation.
204pub fn decode_token(s: &str) -> Result<DelegationToken, JoyError> {
205    let data = s.strip_prefix(TOKEN_PREFIX).ok_or_else(|| {
206        JoyError::AuthFailed("invalid token format (missing joy_t_ prefix)".into())
207    })?;
208    let json = base64_decode(data)?;
209    let token: DelegationToken = serde_json::from_slice(&json)
210        .map_err(|e| JoyError::AuthFailed(format!("invalid token: {e}")))?;
211    Ok(token)
212}
213
214/// Check if a string looks like a delegation token (has the `joy_t_` prefix).
215pub fn is_token(s: &str) -> bool {
216    s.starts_with(TOKEN_PREFIX)
217}
218
219fn base64_encode(data: &[u8]) -> String {
220    use base64ct::{Base64, Encoding};
221    Base64::encode_string(data)
222}
223
224fn base64_decode(s: &str) -> Result<Vec<u8>, JoyError> {
225    use base64ct::{Base64, Encoding};
226    Base64::decode_vec(s).map_err(|e| JoyError::AuthFailed(format!("base64 decode: {e}")))
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::auth::{derive_key, Salt};
233    use chrono::Duration;
234
235    const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
236
237    fn test_keypair() -> (IdentityKeypair, PublicKey) {
238        let salt =
239            Salt::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
240                .unwrap();
241        let key = derive_key(TEST_PASSPHRASE, &salt).unwrap();
242        let kp = IdentityKeypair::from_derived_key(&key);
243        let pk = kp.public_key();
244        (kp, pk)
245    }
246
247    fn fresh_delegation() -> ([u8; 32], IdentityKeypair, PublicKey) {
248        use rand::RngCore;
249        let mut seed = [0u8; 32];
250        rand::thread_rng().fill_bytes(&mut seed);
251        let kp = IdentityKeypair::from_seed(&seed);
252        let pk = kp.public_key();
253        (seed, kp, pk)
254    }
255
256    fn make_token(
257        delegator: &IdentityKeypair,
258        delegation: &IdentityKeypair,
259        seed: &[u8; 32],
260        ttl: Option<Duration>,
261        crypt_scope: bool,
262    ) -> DelegationToken {
263        create_token(
264            TokenSigningKeys {
265                delegator,
266                delegation,
267                delegation_seed: seed,
268            },
269            TokenIssueParams {
270                ai_member: "ai:claude@joy",
271                human: "human@example.com",
272                project_id: "TST",
273                ttl,
274                crypt_scope,
275            },
276        )
277    }
278
279    #[test]
280    fn create_and_validate_token() {
281        let (delegator, delegator_pk) = test_keypair();
282        let (seed, delegation, delegation_pk) = fresh_delegation();
283        let token = make_token(&delegator, &delegation, &seed, None, false);
284        let claims = validate_token(&token, &delegator_pk, &delegation_pk, "TST").unwrap();
285        assert_eq!(claims.ai_member, "ai:claude@joy");
286        assert_eq!(claims.delegated_by, "human@example.com");
287        assert_eq!(token.delegation_public_key, delegation_pk.to_hex());
288        assert!(token.delegation_private_key.is_none());
289        assert!(!claims.has_crypt_scope());
290    }
291
292    #[test]
293    fn crypt_token_carries_seed() {
294        let (delegator, _) = test_keypair();
295        let (seed, delegation, _) = fresh_delegation();
296        let token = make_token(&delegator, &delegation, &seed, None, true);
297        assert_eq!(token.delegation_private_key, Some(hex::encode(seed)));
298        assert!(token.claims.has_crypt_scope());
299    }
300
301    #[test]
302    fn token_with_expiry() {
303        let (delegator, delegator_pk) = test_keypair();
304        let (seed, delegation, delegation_pk) = fresh_delegation();
305        let token = make_token(
306            &delegator,
307            &delegation,
308            &seed,
309            Some(Duration::hours(8)),
310            false,
311        );
312        let claims = validate_token(&token, &delegator_pk, &delegation_pk, "TST").unwrap();
313        assert!(claims.expires.is_some());
314    }
315
316    #[test]
317    fn expired_token_rejected() {
318        let (delegator, delegator_pk) = test_keypair();
319        let (seed, delegation, delegation_pk) = fresh_delegation();
320        let token = make_token(
321            &delegator,
322            &delegation,
323            &seed,
324            Some(Duration::seconds(-1)),
325            false,
326        );
327        assert!(validate_token(&token, &delegator_pk, &delegation_pk, "TST").is_err());
328    }
329
330    #[test]
331    fn wrong_project_rejected() {
332        let (delegator, delegator_pk) = test_keypair();
333        let (seed, delegation, delegation_pk) = fresh_delegation();
334        let token = make_token(&delegator, &delegation, &seed, None, false);
335        assert!(validate_token(&token, &delegator_pk, &delegation_pk, "OTHER").is_err());
336    }
337
338    #[test]
339    fn tampered_claims_rejected() {
340        let (delegator, delegator_pk) = test_keypair();
341        let (seed, delegation, delegation_pk) = fresh_delegation();
342        let mut token = make_token(&delegator, &delegation, &seed, None, false);
343        token.claims.ai_member = "ai:attacker@evil".into();
344        assert!(validate_token(&token, &delegator_pk, &delegation_pk, "TST").is_err());
345    }
346
347    #[test]
348    fn wrong_delegator_key_rejected() {
349        let (delegator, _) = test_keypair();
350        let (seed, delegation, delegation_pk) = fresh_delegation();
351        let token = make_token(&delegator, &delegation, &seed, None, false);
352
353        let other_salt = crate::auth::generate_salt();
354        let other_key = derive_key("alpha bravo charlie delta echo foxtrot", &other_salt).unwrap();
355        let other_kp = IdentityKeypair::from_derived_key(&other_key);
356        let other_pk = other_kp.public_key();
357
358        assert!(validate_token(&token, &other_pk, &delegation_pk, "TST").is_err());
359    }
360
361    #[test]
362    fn wrong_delegation_key_rejected() {
363        let (delegator, delegator_pk) = test_keypair();
364        let (seed, delegation, _) = fresh_delegation();
365        let token = make_token(&delegator, &delegation, &seed, None, false);
366
367        // Simulates rotation: validator looks up a different delegation_key in project.yaml.
368        let (_, _, rotated_pk) = fresh_delegation();
369        assert!(validate_token(&token, &delegator_pk, &rotated_pk, "TST").is_err());
370    }
371
372    #[test]
373    fn encode_decode_roundtrip() {
374        let (delegator, delegator_pk) = test_keypair();
375        let (seed, delegation, delegation_pk) = fresh_delegation();
376        let token = make_token(&delegator, &delegation, &seed, None, false);
377        let encoded = encode_token(&token);
378        assert!(encoded.starts_with("joy_t_"));
379        let decoded = decode_token(&encoded).unwrap();
380        let claims = validate_token(&decoded, &delegator_pk, &delegation_pk, "TST").unwrap();
381        assert_eq!(claims.ai_member, "ai:claude@joy");
382    }
383
384    #[test]
385    fn legacy_token_without_scopes_field_decodes() {
386        // Old tokens (pre-ADR-041) had no `scopes` field in claims. Ensure
387        // they still deserialize, with the default scope ["auth"].
388        let legacy_json = r#"{
389            "claims": {
390                "token_id": "abc",
391                "ai_member": "ai:claude@joy",
392                "delegated_by": "human@example.com",
393                "project_id": "TST",
394                "created": "2026-05-01T00:00:00Z",
395                "expires": null
396            },
397            "delegator_signature": "00",
398            "binding_signature": "00",
399            "delegation_public_key": "00"
400        }"#;
401        let token: DelegationToken = serde_json::from_str(legacy_json).unwrap();
402        assert_eq!(token.claims.scopes, vec!["auth".to_string()]);
403        assert!(!token.claims.has_crypt_scope());
404        assert!(token.delegation_private_key.is_none());
405    }
406
407    #[test]
408    fn invalid_prefix_rejected() {
409        assert!(decode_token("invalid_prefix_data").is_err());
410    }
411}