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.
204///
205/// Whitespace (spaces, newlines, tabs, CRs) is stripped before the
206/// base64 stage so a token that survived an over-eager chat client
207/// line-wrap still decodes. base64 and JSON errors are rewrapped in
208/// a hint that names the most common real cause, namely a truncation
209/// from a wrapped paste, so the operator (or an AI tool) knows to
210/// re-paste rather than retry with new guesses.
211pub fn decode_token(s: &str) -> Result<DelegationToken, JoyError> {
212    let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
213    // Strip a single layer of surrounding `"` or `'` quotes. The
214    // `joy auth token add` output wraps the token in double quotes
215    // so chat clients treat it as one atomic string instead of
216    // word-splitting on visual line wraps. Callers that re-paste
217    // the quoted form into `joy auth --token` get it accepted
218    // verbatim.
219    let trimmed = cleaned
220        .strip_prefix('"')
221        .and_then(|s| s.strip_suffix('"'))
222        .or_else(|| {
223            cleaned
224                .strip_prefix('\'')
225                .and_then(|s| s.strip_suffix('\''))
226        })
227        .unwrap_or(&cleaned);
228    let data = trimmed.strip_prefix(TOKEN_PREFIX).ok_or_else(|| {
229        JoyError::AuthFailed("invalid token format (missing joy_t_ prefix)".into())
230    })?;
231    let json = base64_decode(data).map_err(|e| wrap_decode_error(&e.to_string()))?;
232    let token: DelegationToken =
233        serde_json::from_slice(&json).map_err(|e| wrap_decode_error(&format!("{e}")))?;
234    Ok(token)
235}
236
237fn wrap_decode_error(detail: &str) -> JoyError {
238    JoyError::AuthFailed(format!(
239        "token decode failed: {detail}. \
240         A delegation token is a single base64 line. If this was forwarded \
241         through a chat tool, the visual line wrap may have hidden a \
242         truncation: re-read the operator's original message in full, strip \
243         all whitespace, and retry before asking the operator to paste it \
244         again."
245    ))
246}
247
248/// Check if a string looks like a delegation token (has the `joy_t_` prefix).
249pub fn is_token(s: &str) -> bool {
250    s.starts_with(TOKEN_PREFIX)
251}
252
253fn base64_encode(data: &[u8]) -> String {
254    use base64ct::{Base64, Encoding};
255    Base64::encode_string(data)
256}
257
258fn base64_decode(s: &str) -> Result<Vec<u8>, JoyError> {
259    use base64ct::{Base64, Encoding};
260    Base64::decode_vec(s).map_err(|e| JoyError::AuthFailed(format!("base64 decode: {e}")))
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::auth::{derive_key, Salt};
267    use chrono::Duration;
268
269    const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
270
271    fn test_keypair() -> (IdentityKeypair, PublicKey) {
272        let salt =
273            Salt::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
274                .unwrap();
275        let key = derive_key(TEST_PASSPHRASE, &salt).unwrap();
276        let kp = IdentityKeypair::from_derived_key(&key);
277        let pk = kp.public_key();
278        (kp, pk)
279    }
280
281    fn fresh_delegation() -> ([u8; 32], IdentityKeypair, PublicKey) {
282        use rand::RngCore;
283        let mut seed = [0u8; 32];
284        rand::thread_rng().fill_bytes(&mut seed);
285        let kp = IdentityKeypair::from_seed(&seed);
286        let pk = kp.public_key();
287        (seed, kp, pk)
288    }
289
290    fn make_token(
291        delegator: &IdentityKeypair,
292        delegation: &IdentityKeypair,
293        seed: &[u8; 32],
294        ttl: Option<Duration>,
295        crypt_scope: bool,
296    ) -> DelegationToken {
297        create_token(
298            TokenSigningKeys {
299                delegator,
300                delegation,
301                delegation_seed: seed,
302            },
303            TokenIssueParams {
304                ai_member: "ai:claude@joy",
305                human: "human@example.com",
306                project_id: "TST",
307                ttl,
308                crypt_scope,
309            },
310        )
311    }
312
313    #[test]
314    fn create_and_validate_token() {
315        let (delegator, delegator_pk) = test_keypair();
316        let (seed, delegation, delegation_pk) = fresh_delegation();
317        let token = make_token(&delegator, &delegation, &seed, None, false);
318        let claims = validate_token(&token, &delegator_pk, &delegation_pk, "TST").unwrap();
319        assert_eq!(claims.ai_member, "ai:claude@joy");
320        assert_eq!(claims.delegated_by, "human@example.com");
321        assert_eq!(token.delegation_public_key, delegation_pk.to_hex());
322        assert!(token.delegation_private_key.is_none());
323        assert!(!claims.has_crypt_scope());
324    }
325
326    #[test]
327    fn crypt_token_carries_seed() {
328        let (delegator, _) = test_keypair();
329        let (seed, delegation, _) = fresh_delegation();
330        let token = make_token(&delegator, &delegation, &seed, None, true);
331        assert_eq!(token.delegation_private_key, Some(hex::encode(seed)));
332        assert!(token.claims.has_crypt_scope());
333    }
334
335    #[test]
336    fn token_with_expiry() {
337        let (delegator, delegator_pk) = test_keypair();
338        let (seed, delegation, delegation_pk) = fresh_delegation();
339        let token = make_token(
340            &delegator,
341            &delegation,
342            &seed,
343            Some(Duration::hours(8)),
344            false,
345        );
346        let claims = validate_token(&token, &delegator_pk, &delegation_pk, "TST").unwrap();
347        assert!(claims.expires.is_some());
348    }
349
350    #[test]
351    fn expired_token_rejected() {
352        let (delegator, delegator_pk) = test_keypair();
353        let (seed, delegation, delegation_pk) = fresh_delegation();
354        let token = make_token(
355            &delegator,
356            &delegation,
357            &seed,
358            Some(Duration::seconds(-1)),
359            false,
360        );
361        assert!(validate_token(&token, &delegator_pk, &delegation_pk, "TST").is_err());
362    }
363
364    #[test]
365    fn wrong_project_rejected() {
366        let (delegator, delegator_pk) = test_keypair();
367        let (seed, delegation, delegation_pk) = fresh_delegation();
368        let token = make_token(&delegator, &delegation, &seed, None, false);
369        assert!(validate_token(&token, &delegator_pk, &delegation_pk, "OTHER").is_err());
370    }
371
372    #[test]
373    fn tampered_claims_rejected() {
374        let (delegator, delegator_pk) = test_keypair();
375        let (seed, delegation, delegation_pk) = fresh_delegation();
376        let mut token = make_token(&delegator, &delegation, &seed, None, false);
377        token.claims.ai_member = "ai:attacker@evil".into();
378        assert!(validate_token(&token, &delegator_pk, &delegation_pk, "TST").is_err());
379    }
380
381    #[test]
382    fn wrong_delegator_key_rejected() {
383        let (delegator, _) = test_keypair();
384        let (seed, delegation, delegation_pk) = fresh_delegation();
385        let token = make_token(&delegator, &delegation, &seed, None, false);
386
387        let other_salt = crate::auth::generate_salt();
388        let other_key = derive_key("alpha bravo charlie delta echo foxtrot", &other_salt).unwrap();
389        let other_kp = IdentityKeypair::from_derived_key(&other_key);
390        let other_pk = other_kp.public_key();
391
392        assert!(validate_token(&token, &other_pk, &delegation_pk, "TST").is_err());
393    }
394
395    #[test]
396    fn wrong_delegation_key_rejected() {
397        let (delegator, delegator_pk) = test_keypair();
398        let (seed, delegation, _) = fresh_delegation();
399        let token = make_token(&delegator, &delegation, &seed, None, false);
400
401        // Simulates rotation: validator looks up a different delegation_key in project.yaml.
402        let (_, _, rotated_pk) = fresh_delegation();
403        assert!(validate_token(&token, &delegator_pk, &rotated_pk, "TST").is_err());
404    }
405
406    #[test]
407    fn encode_decode_roundtrip() {
408        let (delegator, delegator_pk) = test_keypair();
409        let (seed, delegation, delegation_pk) = fresh_delegation();
410        let token = make_token(&delegator, &delegation, &seed, None, false);
411        let encoded = encode_token(&token);
412        assert!(encoded.starts_with("joy_t_"));
413        let decoded = decode_token(&encoded).unwrap();
414        let claims = validate_token(&decoded, &delegator_pk, &delegation_pk, "TST").unwrap();
415        assert_eq!(claims.ai_member, "ai:claude@joy");
416    }
417
418    #[test]
419    fn legacy_token_without_scopes_field_decodes() {
420        // Old tokens (pre-ADR-041) had no `scopes` field in claims. Ensure
421        // they still deserialize, with the default scope ["auth"].
422        let legacy_json = r#"{
423            "claims": {
424                "token_id": "abc",
425                "ai_member": "ai:claude@joy",
426                "delegated_by": "human@example.com",
427                "project_id": "TST",
428                "created": "2026-05-01T00:00:00Z",
429                "expires": null
430            },
431            "delegator_signature": "00",
432            "binding_signature": "00",
433            "delegation_public_key": "00"
434        }"#;
435        let token: DelegationToken = serde_json::from_str(legacy_json).unwrap();
436        assert_eq!(token.claims.scopes, vec!["auth".to_string()]);
437        assert!(!token.claims.has_crypt_scope());
438        assert!(token.delegation_private_key.is_none());
439    }
440
441    #[test]
442    fn invalid_prefix_rejected() {
443        assert!(decode_token("invalid_prefix_data").is_err());
444    }
445
446    #[test]
447    fn decode_tolerates_embedded_whitespace() {
448        // Chat clients can visually wrap a token across multiple
449        // lines; some AI tools then forward it with the line
450        // breaks still in the string. Stripping whitespace inside
451        // decode_token recovers that case.
452        let (delegator, _) = test_keypair();
453        let (seed, delegation, _) = fresh_delegation();
454        let token = make_token(&delegator, &delegation, &seed, None, false);
455        let encoded = encode_token(&token);
456
457        // Inject newlines, spaces, and tabs at arbitrary positions.
458        let mangled: String = encoded
459            .as_bytes()
460            .chunks(40)
461            .map(|c| std::str::from_utf8(c).unwrap())
462            .collect::<Vec<_>>()
463            .join(" \n\t");
464        assert!(mangled.contains('\n'));
465        let decoded = decode_token(&mangled).expect("whitespace-mangled token should decode");
466        assert_eq!(decoded.claims.ai_member, "ai:claude@joy");
467    }
468
469    #[test]
470    fn decode_accepts_double_quoted_token() {
471        let (delegator, _) = test_keypair();
472        let (seed, delegation, _) = fresh_delegation();
473        let token = make_token(&delegator, &delegation, &seed, None, false);
474        let encoded = encode_token(&token);
475        let quoted = format!("\"{encoded}\"");
476        let decoded = decode_token(&quoted).expect("double-quoted token should decode");
477        assert_eq!(decoded.claims.ai_member, "ai:claude@joy");
478    }
479
480    #[test]
481    fn decode_accepts_single_quoted_token() {
482        let (delegator, _) = test_keypair();
483        let (seed, delegation, _) = fresh_delegation();
484        let token = make_token(&delegator, &delegation, &seed, None, false);
485        let encoded = encode_token(&token);
486        let quoted = format!("'{encoded}'");
487        let decoded = decode_token(&quoted).expect("single-quoted token should decode");
488        assert_eq!(decoded.claims.ai_member, "ai:claude@joy");
489    }
490
491    #[test]
492    fn truncated_token_surfaces_hint() {
493        let (delegator, _) = test_keypair();
494        let (seed, delegation, _) = fresh_delegation();
495        let token = make_token(&delegator, &delegation, &seed, None, false);
496        let encoded = encode_token(&token);
497
498        // Drop the tail, mimicking what happens when an AI tool
499        // grabs only the first visible line of a wrapped paste.
500        let truncated = &encoded[..encoded.len() / 2];
501        let err = decode_token(truncated).expect_err("truncated token must not decode");
502        let msg = err.to_string();
503        assert!(
504            msg.contains("strip all whitespace"),
505            "expected re-read hint in error, got: {msg}"
506        );
507    }
508}