Skip to main content

koan_core/
auth.rs

1//! Authentication primitives: Ed25519 JWT signing, Argon2id password hashing.
2//!
3//! Ed25519 keypair is generated once and stored in the config directory.
4//! JWTs are signed with EdDSA (Ed25519).
5
6use std::fs;
7use std::path::PathBuf;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
11use ring::signature::KeyPair;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::config;
16
17/// The name a server acts under when nobody signed in: auth switched off, or
18/// the local MCP. No account may take it, or it would inherit what is scoped
19/// to that name.
20pub const ANONYMOUS: &str = "anonymous";
21
22// ---------------------------------------------------------------------------
23// Errors
24// ---------------------------------------------------------------------------
25
26#[derive(Debug, Error)]
27pub enum AuthError {
28    #[error("jwt error: {0}")]
29    Jwt(#[from] jsonwebtoken::errors::Error),
30    #[error("argon2 hash error: {0}")]
31    Hash(String),
32    #[error("password verification failed")]
33    InvalidPassword,
34    #[error("io error: {0}")]
35    Io(#[from] std::io::Error),
36    #[error("keypair not found — run `koan auth setup` first")]
37    NoKeypair,
38    #[error("{0}")]
39    Other(String),
40}
41
42// ---------------------------------------------------------------------------
43// Roles
44// ---------------------------------------------------------------------------
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum Role {
49    Admin,
50    User,
51    Readonly,
52}
53
54impl Role {
55    pub fn as_str(&self) -> &'static str {
56        match self {
57            Role::Admin => "admin",
58            Role::User => "user",
59            Role::Readonly => "readonly",
60        }
61    }
62
63    /// Returns true if this role has at least the given permission level.
64    /// Admin > User > Readonly.
65    pub fn has_permission(&self, required: Role) -> bool {
66        match required {
67            Role::Readonly => true,
68            Role::User => matches!(self, Role::Admin | Role::User),
69            Role::Admin => matches!(self, Role::Admin),
70        }
71    }
72}
73
74impl std::str::FromStr for Role {
75    type Err = String;
76
77    fn from_str(s: &str) -> Result<Self, Self::Err> {
78        match s {
79            "admin" => Ok(Role::Admin),
80            "user" => Ok(Role::User),
81            "readonly" => Ok(Role::Readonly),
82            _ => Err(format!("invalid role: '{s}'")),
83        }
84    }
85}
86
87impl std::fmt::Display for Role {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.write_str(self.as_str())
90    }
91}
92
93// ---------------------------------------------------------------------------
94// JWT Claims
95// ---------------------------------------------------------------------------
96
97#[derive(Debug, Serialize, Deserialize)]
98pub struct Claims {
99    /// Subject — user ID.
100    pub sub: i64,
101    /// Username.
102    pub username: String,
103    /// Role.
104    pub role: String,
105    /// Issued at (unix timestamp).
106    pub iat: u64,
107    /// Expiration (unix timestamp).
108    pub exp: u64,
109    /// The one resource a scoped token is good for; see `MCP_SCOPE`. An
110    /// unscoped token is a session and goes anywhere a session does.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub scope: Option<String>,
113}
114
115/// The scope of a token granted to an MCP client through OAuth: valid at
116/// `/mcp` and nowhere else, so the limits the MCP sets on what a client may do
117/// cannot be stepped round by presenting the same token to GraphQL.
118pub const MCP_SCOPE: &str = "mcp";
119
120// ---------------------------------------------------------------------------
121// Password hashing (Argon2id)
122// ---------------------------------------------------------------------------
123
124/// Hash a password using Argon2id with a random salt.
125pub fn hash_password(password: &str) -> Result<String, AuthError> {
126    use argon2::Argon2;
127    use argon2::password_hash::PasswordHasher;
128
129    Argon2::default()
130        .hash_password(password.as_bytes())
131        .map(|h| h.to_string())
132        .map_err(|e| AuthError::Hash(e.to_string()))
133}
134
135/// Verify a password against an Argon2id hash.
136pub fn verify_password(password: &str, hash: &str) -> Result<(), AuthError> {
137    use argon2::Argon2;
138    use argon2::password_hash::PasswordVerifier;
139    use argon2::password_hash::phc::PasswordHash;
140
141    let parsed = PasswordHash::new(hash).map_err(|e| AuthError::Hash(e.to_string()))?;
142    Argon2::default()
143        .verify_password(password.as_bytes(), &parsed)
144        .map_err(|_| AuthError::InvalidPassword)
145}
146
147// ---------------------------------------------------------------------------
148// Random secrets
149// ---------------------------------------------------------------------------
150
151/// Generate a 256-bit random secret, hex encoded.
152///
153/// Used for bearer-style secrets that are compared verbatim rather than hashed
154/// (introspection key, Subsonic shared secret), so the entropy has to carry the
155/// whole security argument.
156pub fn random_token() -> Result<String, AuthError> {
157    use ring::rand::SecureRandom;
158
159    let mut bytes = [0u8; 32];
160    ring::rand::SystemRandom::new()
161        .fill(&mut bytes)
162        .map_err(|_| AuthError::Hash("rng failure".into()))?;
163    Ok(bytes.iter().map(|b| format!("{:02x}", b)).collect())
164}
165
166/// A new Subsonic API key: 32 random bytes, base64url without padding, so it
167/// travels in a query string unescaped.
168pub fn random_api_key() -> Result<String, AuthError> {
169    use base64::Engine as _;
170    use ring::rand::SecureRandom;
171
172    let mut bytes = [0u8; 32];
173    ring::rand::SystemRandom::new()
174        .fill(&mut bytes)
175        .map_err(|_| AuthError::Hash("rng failure".into()))?;
176    Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
177}
178
179/// SHA-256 of `input`, hex encoded. Refresh tokens are stored under this so a
180/// database read does not yield usable credentials.
181pub fn sha256_hex(input: &str) -> String {
182    ring::digest::digest(&ring::digest::SHA256, input.as_bytes())
183        .as_ref()
184        .iter()
185        .map(|b| format!("{:02x}", b))
186        .collect()
187}
188
189// ---------------------------------------------------------------------------
190// Ed25519 Keypair management
191// ---------------------------------------------------------------------------
192
193// ---------------------------------------------------------------------------
194// Sealed passwords, for Subsonic token auth
195// ---------------------------------------------------------------------------
196//
197// Subsonic clients authenticate with `t = md5(password + salt)`, which can only
198// be checked against the plaintext. Each account's password is therefore also
199// kept sealed with AES-256-GCM under a key in the auth directory, bound to the
200// username so a sealed value copied to another row does not open. The argon2
201// hash stays the authority: a password opened from here is checked against it.
202
203fn subsonic_key_path() -> PathBuf {
204    keypair_dir().join("subsonic.key")
205}
206
207/// The key sealing account passwords, created on first use.
208pub fn subsonic_key() -> Result<[u8; 32], AuthError> {
209    use ring::rand::{SecureRandom, SystemRandom};
210    let path = subsonic_key_path();
211    match fs::read(&path) {
212        Ok(bytes) => {
213            return bytes
214                .try_into()
215                .map_err(|_| AuthError::Other(format!("{} is not a 32-byte key", path.display())));
216        }
217        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
218        Err(e) => return Err(e.into()),
219    }
220    let mut key = [0u8; 32];
221    SystemRandom::new()
222        .fill(&mut key)
223        .map_err(|_| AuthError::Other("no randomness for the Subsonic key".into()))?;
224    fs::create_dir_all(keypair_dir())?;
225    #[cfg(unix)]
226    {
227        use std::io::Write;
228        use std::os::unix::fs::OpenOptionsExt;
229        // create_new: a key another process wrote first is the one to use.
230        match fs::OpenOptions::new()
231            .write(true)
232            .create_new(true)
233            .mode(0o600)
234            .open(&path)
235        {
236            Ok(mut f) => f.write_all(&key)?,
237            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return subsonic_key(),
238            Err(e) => return Err(e.into()),
239        }
240    }
241    #[cfg(not(unix))]
242    fs::write(&path, key)?;
243    Ok(key)
244}
245
246fn sealing_key(key: &[u8; 32]) -> Result<ring::aead::LessSafeKey, AuthError> {
247    use ring::aead::{AES_256_GCM, LessSafeKey, UnboundKey};
248    UnboundKey::new(&AES_256_GCM, key)
249        .map(LessSafeKey::new)
250        .map_err(|_| AuthError::Other("invalid Subsonic key".into()))
251}
252
253/// Seal `password` for `username`: a random nonce, then the ciphertext and tag.
254pub fn seal_password(key: &[u8; 32], username: &str, password: &str) -> Result<Vec<u8>, AuthError> {
255    use ring::aead::{Aad, NONCE_LEN, Nonce};
256    use ring::rand::{SecureRandom, SystemRandom};
257    let mut nonce = [0u8; NONCE_LEN];
258    SystemRandom::new()
259        .fill(&mut nonce)
260        .map_err(|_| AuthError::Other("no randomness for a nonce".into()))?;
261    let mut sealed = password.as_bytes().to_vec();
262    sealing_key(key)?
263        .seal_in_place_append_tag(
264            Nonce::assume_unique_for_key(nonce),
265            Aad::from(username.as_bytes()),
266            &mut sealed,
267        )
268        .map_err(|_| AuthError::Other("sealing failed".into()))?;
269    let mut out = nonce.to_vec();
270    out.extend(sealed);
271    Ok(out)
272}
273
274/// The password sealed for `username`, if `sealed` opens with this key.
275pub fn open_password(key: &[u8; 32], username: &str, sealed: &[u8]) -> Option<String> {
276    use ring::aead::{Aad, NONCE_LEN, Nonce};
277    let (nonce, ciphertext) = sealed.split_at_checked(NONCE_LEN)?;
278    let mut buf = ciphertext.to_vec();
279    let plain = sealing_key(key)
280        .ok()?
281        .open_in_place(
282            Nonce::try_assume_unique_for_key(nonce).ok()?,
283            Aad::from(username.as_bytes()),
284            &mut buf,
285        )
286        .ok()?;
287    String::from_utf8(plain.to_vec()).ok()
288}
289
290pub fn keypair_dir() -> PathBuf {
291    config::config_dir().join("auth")
292}
293
294fn private_key_path() -> PathBuf {
295    keypair_dir().join("ed25519.pem")
296}
297
298fn public_key_path() -> PathBuf {
299    keypair_dir().join("ed25519.pub.pem")
300}
301
302/// Derive a new Ed25519 keypair as PEM. Touches no filesystem state.
303/// Returns (private_pem, public_pem).
304pub fn generate_keypair_pem() -> Result<(String, String), AuthError> {
305    // jsonwebtoken's EncodingKey::from_ed_pem expects PKCS8 PEM.
306    let rng = ring::rand::SystemRandom::new();
307    let pkcs8_doc = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
308        .map_err(|e| AuthError::Other(format!("keypair generation failed: {}", e)))?;
309
310    let private_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", pkcs8_doc.as_ref()));
311
312    // Extract public key from the keypair.
313    let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8_doc.as_ref())
314        .map_err(|e| AuthError::Other(format!("keypair parse failed: {}", e)))?;
315    let pub_bytes = kp.public_key().as_ref();
316
317    // Wrap public key in SubjectPublicKeyInfo DER (for Ed25519 this is a fixed prefix + 32 bytes).
318    // OID 1.3.101.112 = id-EdDSA (Ed25519).
319    let mut spki = vec![
320        0x30, 0x2a, // SEQUENCE, 42 bytes total
321        0x30, 0x05, // SEQUENCE (AlgorithmIdentifier), 5 bytes
322        0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
323        0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused bits
324    ];
325    spki.extend_from_slice(pub_bytes);
326    let public_pem = pem::encode(&pem::Pem::new("PUBLIC KEY", spki));
327
328    Ok((private_pem, public_pem))
329}
330
331/// Generate a new Ed25519 keypair and write PEM files to the config dir.
332/// Returns (private_pem, public_pem).
333pub fn generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
334    let (private_pem, public_pem) = generate_keypair_pem()?;
335
336    let dir = keypair_dir();
337    fs::create_dir_all(&dir)?;
338
339    // Ensure the auth directory is gitignored — keys must never be committed.
340    let gitignore = dir.join(".gitignore");
341    if !gitignore.exists() {
342        let _ = fs::write(&gitignore, "*\n");
343    }
344
345    // Write key files with restrictive permissions set BEFORE writing content
346    // to avoid a window where the file exists with default (world-readable) mode.
347    #[cfg(unix)]
348    {
349        use std::fs::OpenOptions;
350        use std::io::Write;
351        use std::os::unix::fs::OpenOptionsExt;
352        use std::os::unix::fs::PermissionsExt;
353
354        let mut f = OpenOptions::new()
355            .write(true)
356            .create(true)
357            .truncate(true)
358            .mode(0o600)
359            .open(private_key_path())?;
360        f.write_all(private_pem.as_bytes())?;
361
362        let mut f = OpenOptions::new()
363            .write(true)
364            .create(true)
365            .truncate(true)
366            .mode(0o644)
367            .open(public_key_path())?;
368        f.write_all(public_pem.as_bytes())?;
369
370        let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
371    }
372
373    #[cfg(not(unix))]
374    {
375        fs::write(private_key_path(), &private_pem)?;
376        fs::write(public_key_path(), &public_pem)?;
377    }
378
379    Ok((private_pem.into_bytes(), public_pem.into_bytes()))
380}
381
382/// Load the Ed25519 keypair from disk. Returns (private_pem, public_pem).
383pub fn load_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
384    let priv_path = private_key_path();
385    let pub_path = public_key_path();
386
387    if !priv_path.exists() || !pub_path.exists() {
388        return Err(AuthError::NoKeypair);
389    }
390
391    let private_pem = fs::read(&priv_path)?;
392    let public_pem = fs::read(&pub_path)?;
393    Ok((private_pem, public_pem))
394}
395
396/// Load or generate the keypair. Generates if missing.
397pub fn load_or_generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
398    match load_keypair() {
399        Ok(kp) => Ok(kp),
400        Err(AuthError::NoKeypair) => generate_keypair(),
401        Err(e) => Err(e),
402    }
403}
404
405// ---------------------------------------------------------------------------
406// JWT encode / decode
407// ---------------------------------------------------------------------------
408
409/// Mint a new access token.
410pub fn mint_access_token(
411    private_pem: &[u8],
412    user_id: i64,
413    username: &str,
414    role: Role,
415    ttl_secs: u64,
416) -> Result<String, AuthError> {
417    mint_scoped_token(private_pem, user_id, username, role, ttl_secs, None)
418}
419
420/// Mint an access token, limited to `scope` when there is one.
421pub fn mint_scoped_token(
422    private_pem: &[u8],
423    user_id: i64,
424    username: &str,
425    role: Role,
426    ttl_secs: u64,
427    scope: Option<&str>,
428) -> Result<String, AuthError> {
429    let now = now_unix();
430
431    let claims = Claims {
432        sub: user_id,
433        username: username.to_string(),
434        role: role.as_str().to_string(),
435        iat: now,
436        exp: now + ttl_secs,
437        scope: scope.map(str::to_owned),
438    };
439
440    let key = EncodingKey::from_ed_pem(private_pem)?;
441    let header = Header::new(Algorithm::EdDSA);
442    let token = jsonwebtoken::encode(&header, &claims, &key)?;
443    Ok(token)
444}
445
446/// Validate a session's access token and return its claims. A scoped token is
447/// refused: it is good only where its scope is checked for.
448pub fn validate_access_token(public_pem: &[u8], token: &str) -> Result<Claims, AuthError> {
449    validate_scoped_token(public_pem, token, None)
450}
451
452/// Validate an access token whose scope is exactly `scope`.
453pub fn validate_scoped_token(
454    public_pem: &[u8],
455    token: &str,
456    scope: Option<&str>,
457) -> Result<Claims, AuthError> {
458    let key = DecodingKey::from_ed_pem(public_pem)?;
459    let mut validation = Validation::new(Algorithm::EdDSA);
460    // Only require exp (expiry). sub and iat are custom fields, not JWT spec strings.
461    validation.set_required_spec_claims(&["exp"]);
462
463    let claims = jsonwebtoken::decode::<Claims>(token, &key, &validation)?.claims;
464    if claims.scope.as_deref() != scope {
465        return Err(AuthError::Other("token not valid here".into()));
466    }
467    Ok(claims)
468}
469
470// ---------------------------------------------------------------------------
471// Time helpers
472// ---------------------------------------------------------------------------
473
474pub fn now_unix() -> u64 {
475    SystemTime::now()
476        .duration_since(UNIX_EPOCH)
477        .unwrap()
478        .as_secs()
479}
480
481/// Parse a duration string like "15m", "7d", "24h", "3600s" into seconds.
482pub fn parse_duration_secs(s: &str) -> Option<u64> {
483    let s = s.trim();
484    if s.is_empty() {
485        return None;
486    }
487
488    let (num_str, multiplier) = if let Some(n) = s.strip_suffix('d') {
489        (n, 86400)
490    } else if let Some(n) = s.strip_suffix('h') {
491        (n, 3600)
492    } else if let Some(n) = s.strip_suffix('m') {
493        (n, 60)
494    } else if let Some(n) = s.strip_suffix('s') {
495        (n, 1)
496    } else {
497        (s, 1)
498    };
499
500    let num: u64 = num_str.parse().ok()?;
501    Some(num * multiplier)
502}
503
504// ---------------------------------------------------------------------------
505// Tests
506// ---------------------------------------------------------------------------
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn a_scoped_token_is_good_only_where_its_scope_is_asked_for() {
514        let (private, public) = generate_keypair_pem().unwrap();
515        let scoped = mint_scoped_token(
516            private.as_bytes(),
517            1,
518            "user",
519            Role::Admin,
520            900,
521            Some(MCP_SCOPE),
522        )
523        .unwrap();
524        assert!(validate_access_token(public.as_bytes(), &scoped).is_err());
525        assert!(validate_scoped_token(public.as_bytes(), &scoped, Some(MCP_SCOPE)).is_ok());
526        let session = mint_access_token(private.as_bytes(), 1, "user", Role::Admin, 900).unwrap();
527        assert!(validate_scoped_token(public.as_bytes(), &session, Some(MCP_SCOPE)).is_err());
528    }
529
530    #[test]
531    fn password_hash_and_verify() {
532        let password = "hunter2";
533        let hash = hash_password(password).unwrap();
534        assert!(hash.starts_with("$argon2"));
535        verify_password(password, &hash).unwrap();
536    }
537
538    #[test]
539    fn password_verify_wrong() {
540        let hash = hash_password("correct").unwrap();
541        let result = verify_password("wrong", &hash);
542        assert!(matches!(result, Err(AuthError::InvalidPassword)));
543    }
544
545    /// Hashed by argon2 0.5. Every stored password was, so this is what a
546    /// dependency bump must never stop accepting.
547    #[test]
548    fn password_verify_hash_from_argon2_0_5() {
549        let hash = "$argon2id$v=19$m=19456,t=2,p=1$M/zwWdjjbwOvNCjzP+5t5A$pflXrbL1iOYPBlbgtK59wr2PkBaH7UVLKoBisvJ+Yfk";
550        verify_password("correct horse", hash).unwrap();
551        assert!(matches!(
552            verify_password("wrong horse", hash),
553            Err(AuthError::InvalidPassword)
554        ));
555    }
556
557    #[test]
558    fn keypair_generate_and_jwt_roundtrip() {
559        let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
560
561        let token =
562            mint_access_token(priv_pem.as_bytes(), 42, "testuser", Role::Admin, 3600).unwrap();
563        let claims = validate_access_token(pub_pem.as_bytes(), &token).unwrap();
564
565        assert_eq!(claims.sub, 42);
566        assert_eq!(claims.username, "testuser");
567        assert_eq!(claims.role, "admin");
568    }
569
570    #[test]
571    fn expired_token_rejected() {
572        let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
573        // Manually create a token that expired 10 minutes ago.
574        let now = std::time::SystemTime::now()
575            .duration_since(std::time::UNIX_EPOCH)
576            .unwrap()
577            .as_secs();
578        let claims = Claims {
579            sub: 1,
580            username: "user".into(),
581            role: "user".into(),
582            iat: now - 1200,
583            exp: now - 600, // expired 10 min ago
584            scope: None,
585        };
586        let key = jsonwebtoken::EncodingKey::from_ed_pem(priv_pem.as_bytes()).unwrap();
587        let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
588        let token = jsonwebtoken::encode(&header, &claims, &key).unwrap();
589        let result = validate_access_token(pub_pem.as_bytes(), &token);
590        assert!(result.is_err());
591    }
592
593    #[test]
594    fn role_permissions() {
595        assert!(Role::Admin.has_permission(Role::Admin));
596        assert!(Role::Admin.has_permission(Role::User));
597        assert!(Role::Admin.has_permission(Role::Readonly));
598
599        assert!(!Role::User.has_permission(Role::Admin));
600        assert!(Role::User.has_permission(Role::User));
601        assert!(Role::User.has_permission(Role::Readonly));
602
603        assert!(!Role::Readonly.has_permission(Role::Admin));
604        assert!(!Role::Readonly.has_permission(Role::User));
605        assert!(Role::Readonly.has_permission(Role::Readonly));
606    }
607
608    #[test]
609    fn parse_duration() {
610        assert_eq!(parse_duration_secs("15m"), Some(900));
611        assert_eq!(parse_duration_secs("7d"), Some(604800));
612        assert_eq!(parse_duration_secs("24h"), Some(86400));
613        assert_eq!(parse_duration_secs("3600s"), Some(3600));
614        assert_eq!(parse_duration_secs("3600"), Some(3600));
615        assert_eq!(parse_duration_secs(""), None);
616    }
617}