Skip to main content

joy_core/auth/
token.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Per-delegator AI delegation tokens with dual signatures (ADR-023).
5//!
6//! Each token carries two Ed25519 signatures:
7//! 1. Delegator signature (human's identity key) — proves authorization
8//! 2. Token binding signature (one-time token_key) — binds to project.yaml entry
9//!
10//! Tokens are passed via `--token` flag or `JOY_TOKEN` env var to `joy auth`.
11
12use chrono::{DateTime, Duration, Utc};
13use serde::{Deserialize, Serialize};
14
15use super::sign::{IdentityKeypair, PublicKey};
16use crate::error::JoyError;
17
18/// Token prefix for visual identification.
19const TOKEN_PREFIX: &str = "joy_t_";
20
21/// Claims encoded in a delegation token.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct DelegationClaims {
24    pub ai_member: String,
25    pub delegated_by: String,
26    pub project_id: String,
27    pub created: DateTime<Utc>,
28    pub expires: Option<DateTime<Utc>>,
29}
30
31/// A delegation token with dual signatures.
32#[derive(Debug, Serialize, Deserialize)]
33pub struct DelegationToken {
34    pub claims: DelegationClaims,
35    /// Hex-encoded Ed25519 signature by the delegating human's key.
36    pub delegator_signature: String,
37    /// Hex-encoded Ed25519 signature by the one-time token_key.
38    pub binding_signature: String,
39    /// Hex-encoded public key of the one-time token_key (for matching against project.yaml).
40    pub token_public_key: String,
41}
42
43/// Result of creating a token: the encoded token string + the public key to store.
44pub struct CreateTokenResult {
45    pub token: DelegationToken,
46    pub token_public_key: String,
47}
48
49/// Create a delegation token with dual signatures.
50///
51/// Returns the token and the token_key public key (to store in project.yaml).
52pub fn create_token(
53    delegator_keypair: &IdentityKeypair,
54    ai_member: &str,
55    human: &str,
56    project_id: &str,
57    ttl: Option<Duration>,
58) -> CreateTokenResult {
59    let now = Utc::now();
60    let claims = DelegationClaims {
61        ai_member: ai_member.to_string(),
62        delegated_by: human.to_string(),
63        project_id: project_id.to_string(),
64        created: now,
65        expires: ttl.map(|d| now + d),
66    };
67    let claims_json = serde_json::to_string(&claims).expect("claims serialize");
68
69    // Signature 1: delegator's identity key
70    let delegator_sig = delegator_keypair.sign(claims_json.as_bytes());
71
72    // Signature 2: one-time token key (generated fresh)
73    let token_keypair = IdentityKeypair::from_random();
74    let binding_sig = token_keypair.sign(claims_json.as_bytes());
75    let token_pk = token_keypair.public_key();
76
77    CreateTokenResult {
78        token: DelegationToken {
79            claims,
80            delegator_signature: hex::encode(delegator_sig),
81            binding_signature: hex::encode(binding_sig),
82            token_public_key: token_pk.to_hex(),
83        },
84        token_public_key: token_pk.to_hex(),
85    }
86}
87
88/// Validate a delegation token against both the delegator's key and the token_key.
89pub fn validate_token(
90    token: &DelegationToken,
91    delegator_pk: &PublicKey,
92    token_pk: &PublicKey,
93    project_id: &str,
94) -> Result<DelegationClaims, JoyError> {
95    // Check project match
96    if token.claims.project_id != project_id {
97        return Err(JoyError::AuthFailed(
98            "token belongs to a different project".into(),
99        ));
100    }
101
102    // Check expiry
103    if let Some(expires) = token.claims.expires {
104        if Utc::now() > expires {
105            return Err(JoyError::AuthFailed("delegation token expired".into()));
106        }
107    }
108
109    let claims_json = serde_json::to_string(&token.claims).expect("claims serialize");
110
111    // Verify delegator signature
112    let delegator_sig = hex::decode(&token.delegator_signature)
113        .map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
114    delegator_pk.verify(claims_json.as_bytes(), &delegator_sig)?;
115
116    // Verify binding signature
117    let binding_sig =
118        hex::decode(&token.binding_signature).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
119    token_pk.verify(claims_json.as_bytes(), &binding_sig)?;
120
121    Ok(token.claims.clone())
122}
123
124/// Encode a token as a portable string (`joy_t_<base64>`).
125pub fn encode_token(token: &DelegationToken) -> String {
126    let json = serde_json::to_string(token).expect("token serialize");
127    let encoded = base64_encode(json.as_bytes());
128    format!("{TOKEN_PREFIX}{encoded}")
129}
130
131/// Decode a token from its portable string representation.
132pub fn decode_token(s: &str) -> Result<DelegationToken, JoyError> {
133    let data = s.strip_prefix(TOKEN_PREFIX).ok_or_else(|| {
134        JoyError::AuthFailed("invalid token format (missing joy_t_ prefix)".into())
135    })?;
136    let json = base64_decode(data)?;
137    let token: DelegationToken = serde_json::from_slice(&json)
138        .map_err(|e| JoyError::AuthFailed(format!("invalid token: {e}")))?;
139    Ok(token)
140}
141
142/// Check if a string looks like a delegation token (has the `joy_t_` prefix).
143pub fn is_token(s: &str) -> bool {
144    s.starts_with(TOKEN_PREFIX)
145}
146
147fn base64_encode(data: &[u8]) -> String {
148    use base64ct::{Base64, Encoding};
149    Base64::encode_string(data)
150}
151
152fn base64_decode(s: &str) -> Result<Vec<u8>, JoyError> {
153    use base64ct::{Base64, Encoding};
154    Base64::decode_vec(s).map_err(|e| JoyError::AuthFailed(format!("base64 decode: {e}")))
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::auth::{derive, sign};
161    use chrono::Duration;
162
163    const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
164
165    fn test_keypair() -> (sign::IdentityKeypair, sign::PublicKey) {
166        let salt = derive::Salt::from_hex(
167            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
168        )
169        .unwrap();
170        let key = derive::derive_key(TEST_PASSPHRASE, &salt).unwrap();
171        let kp = sign::IdentityKeypair::from_derived_key(&key);
172        let pk = kp.public_key();
173        (kp, pk)
174    }
175
176    #[test]
177    fn create_and_validate_token() {
178        let (kp, pk) = test_keypair();
179        let result = create_token(&kp, "ai:claude@joy", "human@example.com", "TST", None);
180        let token_pk = sign::PublicKey::from_hex(&result.token_public_key).unwrap();
181        let claims = validate_token(&result.token, &pk, &token_pk, "TST").unwrap();
182        assert_eq!(claims.ai_member, "ai:claude@joy");
183        assert_eq!(claims.delegated_by, "human@example.com");
184    }
185
186    #[test]
187    fn token_with_expiry() {
188        let (kp, pk) = test_keypair();
189        let result = create_token(
190            &kp,
191            "ai:claude@joy",
192            "human@example.com",
193            "TST",
194            Some(Duration::hours(8)),
195        );
196        let token_pk = sign::PublicKey::from_hex(&result.token_public_key).unwrap();
197        let claims = validate_token(&result.token, &pk, &token_pk, "TST").unwrap();
198        assert!(claims.expires.is_some());
199    }
200
201    #[test]
202    fn expired_token_rejected() {
203        let (kp, pk) = test_keypair();
204        let result = create_token(
205            &kp,
206            "ai:claude@joy",
207            "human@example.com",
208            "TST",
209            Some(Duration::seconds(-1)),
210        );
211        let token_pk = sign::PublicKey::from_hex(&result.token_public_key).unwrap();
212        assert!(validate_token(&result.token, &pk, &token_pk, "TST").is_err());
213    }
214
215    #[test]
216    fn wrong_project_rejected() {
217        let (kp, pk) = test_keypair();
218        let result = create_token(&kp, "ai:claude@joy", "human@example.com", "TST", None);
219        let token_pk = sign::PublicKey::from_hex(&result.token_public_key).unwrap();
220        assert!(validate_token(&result.token, &pk, &token_pk, "OTHER").is_err());
221    }
222
223    #[test]
224    fn tampered_claims_rejected() {
225        let (kp, pk) = test_keypair();
226        let result = create_token(&kp, "ai:claude@joy", "human@example.com", "TST", None);
227        let token_pk = sign::PublicKey::from_hex(&result.token_public_key).unwrap();
228        let mut token = result.token;
229        token.claims.ai_member = "ai:attacker@evil".into();
230        assert!(validate_token(&token, &pk, &token_pk, "TST").is_err());
231    }
232
233    #[test]
234    fn wrong_delegator_key_rejected() {
235        let (kp, _) = test_keypair();
236        let result = create_token(&kp, "ai:claude@joy", "human@example.com", "TST", None);
237        let token_pk = sign::PublicKey::from_hex(&result.token_public_key).unwrap();
238
239        // Different delegator key
240        let other_salt = derive::generate_salt();
241        let other_key =
242            derive::derive_key("alpha bravo charlie delta echo foxtrot", &other_salt).unwrap();
243        let other_kp = sign::IdentityKeypair::from_derived_key(&other_key);
244        let other_pk = other_kp.public_key();
245
246        assert!(validate_token(&result.token, &other_pk, &token_pk, "TST").is_err());
247    }
248
249    #[test]
250    fn wrong_token_key_rejected() {
251        let (kp, pk) = test_keypair();
252        let result = create_token(&kp, "ai:claude@joy", "human@example.com", "TST", None);
253
254        // Different token key (simulates revoked token)
255        let wrong_token_kp = sign::IdentityKeypair::from_random();
256        let wrong_token_pk = wrong_token_kp.public_key();
257
258        assert!(validate_token(&result.token, &pk, &wrong_token_pk, "TST").is_err());
259    }
260
261    #[test]
262    fn encode_decode_roundtrip() {
263        let (kp, pk) = test_keypair();
264        let result = create_token(&kp, "ai:claude@joy", "human@example.com", "TST", None);
265        let encoded = encode_token(&result.token);
266        assert!(encoded.starts_with("joy_t_"));
267        let decoded = decode_token(&encoded).unwrap();
268        let token_pk = sign::PublicKey::from_hex(&result.token_public_key).unwrap();
269        let claims = validate_token(&decoded, &pk, &token_pk, "TST").unwrap();
270        assert_eq!(claims.ai_member, "ai:claude@joy");
271    }
272
273    #[test]
274    fn invalid_prefix_rejected() {
275        assert!(decode_token("invalid_prefix_data").is_err());
276    }
277}