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