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}
110
111// ---------------------------------------------------------------------------
112// Password hashing (Argon2id)
113// ---------------------------------------------------------------------------
114
115/// Hash a password using Argon2id with a random salt.
116pub fn hash_password(password: &str) -> Result<String, AuthError> {
117    use argon2::Argon2;
118    use argon2::password_hash::PasswordHasher;
119
120    Argon2::default()
121        .hash_password(password.as_bytes())
122        .map(|h| h.to_string())
123        .map_err(|e| AuthError::Hash(e.to_string()))
124}
125
126/// Verify a password against an Argon2id hash.
127pub fn verify_password(password: &str, hash: &str) -> Result<(), AuthError> {
128    use argon2::Argon2;
129    use argon2::password_hash::PasswordVerifier;
130    use argon2::password_hash::phc::PasswordHash;
131
132    let parsed = PasswordHash::new(hash).map_err(|e| AuthError::Hash(e.to_string()))?;
133    Argon2::default()
134        .verify_password(password.as_bytes(), &parsed)
135        .map_err(|_| AuthError::InvalidPassword)
136}
137
138// ---------------------------------------------------------------------------
139// Random secrets
140// ---------------------------------------------------------------------------
141
142/// Generate a 256-bit random secret, hex encoded.
143///
144/// Used for bearer-style secrets that are compared verbatim rather than hashed
145/// (introspection key, Subsonic shared secret), so the entropy has to carry the
146/// whole security argument.
147pub fn random_token() -> Result<String, AuthError> {
148    use ring::rand::SecureRandom;
149
150    let mut bytes = [0u8; 32];
151    ring::rand::SystemRandom::new()
152        .fill(&mut bytes)
153        .map_err(|_| AuthError::Hash("rng failure".into()))?;
154    Ok(bytes.iter().map(|b| format!("{:02x}", b)).collect())
155}
156
157/// A new Subsonic API key: 32 random bytes, base64url without padding, so it
158/// travels in a query string unescaped.
159pub fn random_api_key() -> Result<String, AuthError> {
160    use base64::Engine as _;
161    use ring::rand::SecureRandom;
162
163    let mut bytes = [0u8; 32];
164    ring::rand::SystemRandom::new()
165        .fill(&mut bytes)
166        .map_err(|_| AuthError::Hash("rng failure".into()))?;
167    Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
168}
169
170/// SHA-256 of `input`, hex encoded. Refresh tokens are stored under this so a
171/// database read does not yield usable credentials.
172pub fn sha256_hex(input: &str) -> String {
173    ring::digest::digest(&ring::digest::SHA256, input.as_bytes())
174        .as_ref()
175        .iter()
176        .map(|b| format!("{:02x}", b))
177        .collect()
178}
179
180// ---------------------------------------------------------------------------
181// Ed25519 Keypair management
182// ---------------------------------------------------------------------------
183
184// ---------------------------------------------------------------------------
185// Sealed passwords, for Subsonic token auth
186// ---------------------------------------------------------------------------
187//
188// Subsonic clients authenticate with `t = md5(password + salt)`, which can only
189// be checked against the plaintext. Each account's password is therefore also
190// kept sealed with AES-256-GCM under a key in the auth directory, bound to the
191// username so a sealed value copied to another row does not open. The argon2
192// hash stays the authority: a password opened from here is checked against it.
193
194fn subsonic_key_path() -> PathBuf {
195    keypair_dir().join("subsonic.key")
196}
197
198/// The key sealing account passwords, created on first use.
199pub fn subsonic_key() -> Result<[u8; 32], AuthError> {
200    use ring::rand::{SecureRandom, SystemRandom};
201    let path = subsonic_key_path();
202    match fs::read(&path) {
203        Ok(bytes) => {
204            return bytes
205                .try_into()
206                .map_err(|_| AuthError::Other(format!("{} is not a 32-byte key", path.display())));
207        }
208        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
209        Err(e) => return Err(e.into()),
210    }
211    let mut key = [0u8; 32];
212    SystemRandom::new()
213        .fill(&mut key)
214        .map_err(|_| AuthError::Other("no randomness for the Subsonic key".into()))?;
215    fs::create_dir_all(keypair_dir())?;
216    #[cfg(unix)]
217    {
218        use std::io::Write;
219        use std::os::unix::fs::OpenOptionsExt;
220        // create_new: a key another process wrote first is the one to use.
221        match fs::OpenOptions::new()
222            .write(true)
223            .create_new(true)
224            .mode(0o600)
225            .open(&path)
226        {
227            Ok(mut f) => f.write_all(&key)?,
228            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return subsonic_key(),
229            Err(e) => return Err(e.into()),
230        }
231    }
232    #[cfg(not(unix))]
233    fs::write(&path, key)?;
234    Ok(key)
235}
236
237fn sealing_key(key: &[u8; 32]) -> Result<ring::aead::LessSafeKey, AuthError> {
238    use ring::aead::{AES_256_GCM, LessSafeKey, UnboundKey};
239    UnboundKey::new(&AES_256_GCM, key)
240        .map(LessSafeKey::new)
241        .map_err(|_| AuthError::Other("invalid Subsonic key".into()))
242}
243
244/// Seal `password` for `username`: a random nonce, then the ciphertext and tag.
245pub fn seal_password(key: &[u8; 32], username: &str, password: &str) -> Result<Vec<u8>, AuthError> {
246    use ring::aead::{Aad, NONCE_LEN, Nonce};
247    use ring::rand::{SecureRandom, SystemRandom};
248    let mut nonce = [0u8; NONCE_LEN];
249    SystemRandom::new()
250        .fill(&mut nonce)
251        .map_err(|_| AuthError::Other("no randomness for a nonce".into()))?;
252    let mut sealed = password.as_bytes().to_vec();
253    sealing_key(key)?
254        .seal_in_place_append_tag(
255            Nonce::assume_unique_for_key(nonce),
256            Aad::from(username.as_bytes()),
257            &mut sealed,
258        )
259        .map_err(|_| AuthError::Other("sealing failed".into()))?;
260    let mut out = nonce.to_vec();
261    out.extend(sealed);
262    Ok(out)
263}
264
265/// The password sealed for `username`, if `sealed` opens with this key.
266pub fn open_password(key: &[u8; 32], username: &str, sealed: &[u8]) -> Option<String> {
267    use ring::aead::{Aad, NONCE_LEN, Nonce};
268    let (nonce, ciphertext) = sealed.split_at_checked(NONCE_LEN)?;
269    let mut buf = ciphertext.to_vec();
270    let plain = sealing_key(key)
271        .ok()?
272        .open_in_place(
273            Nonce::try_assume_unique_for_key(nonce).ok()?,
274            Aad::from(username.as_bytes()),
275            &mut buf,
276        )
277        .ok()?;
278    String::from_utf8(plain.to_vec()).ok()
279}
280
281pub fn keypair_dir() -> PathBuf {
282    config::config_dir().join("auth")
283}
284
285fn private_key_path() -> PathBuf {
286    keypair_dir().join("ed25519.pem")
287}
288
289fn public_key_path() -> PathBuf {
290    keypair_dir().join("ed25519.pub.pem")
291}
292
293/// Derive a new Ed25519 keypair as PEM. Touches no filesystem state.
294/// Returns (private_pem, public_pem).
295pub fn generate_keypair_pem() -> Result<(String, String), AuthError> {
296    // jsonwebtoken's EncodingKey::from_ed_pem expects PKCS8 PEM.
297    let rng = ring::rand::SystemRandom::new();
298    let pkcs8_doc = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
299        .map_err(|e| AuthError::Other(format!("keypair generation failed: {}", e)))?;
300
301    let private_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", pkcs8_doc.as_ref()));
302
303    // Extract public key from the keypair.
304    let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8_doc.as_ref())
305        .map_err(|e| AuthError::Other(format!("keypair parse failed: {}", e)))?;
306    let pub_bytes = kp.public_key().as_ref();
307
308    // Wrap public key in SubjectPublicKeyInfo DER (for Ed25519 this is a fixed prefix + 32 bytes).
309    // OID 1.3.101.112 = id-EdDSA (Ed25519).
310    let mut spki = vec![
311        0x30, 0x2a, // SEQUENCE, 42 bytes total
312        0x30, 0x05, // SEQUENCE (AlgorithmIdentifier), 5 bytes
313        0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
314        0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused bits
315    ];
316    spki.extend_from_slice(pub_bytes);
317    let public_pem = pem::encode(&pem::Pem::new("PUBLIC KEY", spki));
318
319    Ok((private_pem, public_pem))
320}
321
322/// Generate a new Ed25519 keypair and write PEM files to the config dir.
323/// Returns (private_pem, public_pem).
324pub fn generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
325    let (private_pem, public_pem) = generate_keypair_pem()?;
326
327    let dir = keypair_dir();
328    fs::create_dir_all(&dir)?;
329
330    // Ensure the auth directory is gitignored — keys must never be committed.
331    let gitignore = dir.join(".gitignore");
332    if !gitignore.exists() {
333        let _ = fs::write(&gitignore, "*\n");
334    }
335
336    // Write key files with restrictive permissions set BEFORE writing content
337    // to avoid a window where the file exists with default (world-readable) mode.
338    #[cfg(unix)]
339    {
340        use std::fs::OpenOptions;
341        use std::io::Write;
342        use std::os::unix::fs::OpenOptionsExt;
343        use std::os::unix::fs::PermissionsExt;
344
345        let mut f = OpenOptions::new()
346            .write(true)
347            .create(true)
348            .truncate(true)
349            .mode(0o600)
350            .open(private_key_path())?;
351        f.write_all(private_pem.as_bytes())?;
352
353        let mut f = OpenOptions::new()
354            .write(true)
355            .create(true)
356            .truncate(true)
357            .mode(0o644)
358            .open(public_key_path())?;
359        f.write_all(public_pem.as_bytes())?;
360
361        let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
362    }
363
364    #[cfg(not(unix))]
365    {
366        fs::write(private_key_path(), &private_pem)?;
367        fs::write(public_key_path(), &public_pem)?;
368    }
369
370    Ok((private_pem.into_bytes(), public_pem.into_bytes()))
371}
372
373/// Load the Ed25519 keypair from disk. Returns (private_pem, public_pem).
374pub fn load_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
375    let priv_path = private_key_path();
376    let pub_path = public_key_path();
377
378    if !priv_path.exists() || !pub_path.exists() {
379        return Err(AuthError::NoKeypair);
380    }
381
382    let private_pem = fs::read(&priv_path)?;
383    let public_pem = fs::read(&pub_path)?;
384    Ok((private_pem, public_pem))
385}
386
387/// Load or generate the keypair. Generates if missing.
388pub fn load_or_generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
389    match load_keypair() {
390        Ok(kp) => Ok(kp),
391        Err(AuthError::NoKeypair) => generate_keypair(),
392        Err(e) => Err(e),
393    }
394}
395
396// ---------------------------------------------------------------------------
397// JWT encode / decode
398// ---------------------------------------------------------------------------
399
400/// Mint a new access token.
401pub fn mint_access_token(
402    private_pem: &[u8],
403    user_id: i64,
404    username: &str,
405    role: Role,
406    ttl_secs: u64,
407) -> Result<String, AuthError> {
408    let now = now_unix();
409
410    let claims = Claims {
411        sub: user_id,
412        username: username.to_string(),
413        role: role.as_str().to_string(),
414        iat: now,
415        exp: now + ttl_secs,
416    };
417
418    let key = EncodingKey::from_ed_pem(private_pem)?;
419    let header = Header::new(Algorithm::EdDSA);
420    let token = jsonwebtoken::encode(&header, &claims, &key)?;
421    Ok(token)
422}
423
424/// Validate an access token and return its claims.
425pub fn validate_access_token(public_pem: &[u8], token: &str) -> Result<Claims, AuthError> {
426    let key = DecodingKey::from_ed_pem(public_pem)?;
427    let mut validation = Validation::new(Algorithm::EdDSA);
428    // Only require exp (expiry). sub and iat are custom fields, not JWT spec strings.
429    validation.set_required_spec_claims(&["exp"]);
430
431    let data = jsonwebtoken::decode::<Claims>(token, &key, &validation)?;
432    Ok(data.claims)
433}
434
435// ---------------------------------------------------------------------------
436// Time helpers
437// ---------------------------------------------------------------------------
438
439pub fn now_unix() -> u64 {
440    SystemTime::now()
441        .duration_since(UNIX_EPOCH)
442        .unwrap()
443        .as_secs()
444}
445
446/// Parse a duration string like "15m", "7d", "24h", "3600s" into seconds.
447pub fn parse_duration_secs(s: &str) -> Option<u64> {
448    let s = s.trim();
449    if s.is_empty() {
450        return None;
451    }
452
453    let (num_str, multiplier) = if let Some(n) = s.strip_suffix('d') {
454        (n, 86400)
455    } else if let Some(n) = s.strip_suffix('h') {
456        (n, 3600)
457    } else if let Some(n) = s.strip_suffix('m') {
458        (n, 60)
459    } else if let Some(n) = s.strip_suffix('s') {
460        (n, 1)
461    } else {
462        (s, 1)
463    };
464
465    let num: u64 = num_str.parse().ok()?;
466    Some(num * multiplier)
467}
468
469// ---------------------------------------------------------------------------
470// Tests
471// ---------------------------------------------------------------------------
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn password_hash_and_verify() {
479        let password = "hunter2";
480        let hash = hash_password(password).unwrap();
481        assert!(hash.starts_with("$argon2"));
482        verify_password(password, &hash).unwrap();
483    }
484
485    #[test]
486    fn password_verify_wrong() {
487        let hash = hash_password("correct").unwrap();
488        let result = verify_password("wrong", &hash);
489        assert!(matches!(result, Err(AuthError::InvalidPassword)));
490    }
491
492    /// Hashed by argon2 0.5. Every stored password was, so this is what a
493    /// dependency bump must never stop accepting.
494    #[test]
495    fn password_verify_hash_from_argon2_0_5() {
496        let hash = "$argon2id$v=19$m=19456,t=2,p=1$M/zwWdjjbwOvNCjzP+5t5A$pflXrbL1iOYPBlbgtK59wr2PkBaH7UVLKoBisvJ+Yfk";
497        verify_password("correct horse", hash).unwrap();
498        assert!(matches!(
499            verify_password("wrong horse", hash),
500            Err(AuthError::InvalidPassword)
501        ));
502    }
503
504    #[test]
505    fn keypair_generate_and_jwt_roundtrip() {
506        let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
507
508        let token =
509            mint_access_token(priv_pem.as_bytes(), 42, "testuser", Role::Admin, 3600).unwrap();
510        let claims = validate_access_token(pub_pem.as_bytes(), &token).unwrap();
511
512        assert_eq!(claims.sub, 42);
513        assert_eq!(claims.username, "testuser");
514        assert_eq!(claims.role, "admin");
515    }
516
517    #[test]
518    fn expired_token_rejected() {
519        let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
520        // Manually create a token that expired 10 minutes ago.
521        let now = std::time::SystemTime::now()
522            .duration_since(std::time::UNIX_EPOCH)
523            .unwrap()
524            .as_secs();
525        let claims = Claims {
526            sub: 1,
527            username: "user".into(),
528            role: "user".into(),
529            iat: now - 1200,
530            exp: now - 600, // expired 10 min ago
531        };
532        let key = jsonwebtoken::EncodingKey::from_ed_pem(priv_pem.as_bytes()).unwrap();
533        let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
534        let token = jsonwebtoken::encode(&header, &claims, &key).unwrap();
535        let result = validate_access_token(pub_pem.as_bytes(), &token);
536        assert!(result.is_err());
537    }
538
539    #[test]
540    fn role_permissions() {
541        assert!(Role::Admin.has_permission(Role::Admin));
542        assert!(Role::Admin.has_permission(Role::User));
543        assert!(Role::Admin.has_permission(Role::Readonly));
544
545        assert!(!Role::User.has_permission(Role::Admin));
546        assert!(Role::User.has_permission(Role::User));
547        assert!(Role::User.has_permission(Role::Readonly));
548
549        assert!(!Role::Readonly.has_permission(Role::Admin));
550        assert!(!Role::Readonly.has_permission(Role::User));
551        assert!(Role::Readonly.has_permission(Role::Readonly));
552    }
553
554    #[test]
555    fn parse_duration() {
556        assert_eq!(parse_duration_secs("15m"), Some(900));
557        assert_eq!(parse_duration_secs("7d"), Some(604800));
558        assert_eq!(parse_duration_secs("24h"), Some(86400));
559        assert_eq!(parse_duration_secs("3600s"), Some(3600));
560        assert_eq!(parse_duration_secs("3600"), Some(3600));
561        assert_eq!(parse_duration_secs(""), None);
562    }
563}