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/// SHA-256 of `input`, hex encoded. Refresh tokens are stored under this so a
153/// database read does not yield usable credentials.
154pub fn sha256_hex(input: &str) -> String {
155    ring::digest::digest(&ring::digest::SHA256, input.as_bytes())
156        .as_ref()
157        .iter()
158        .map(|b| format!("{:02x}", b))
159        .collect()
160}
161
162// ---------------------------------------------------------------------------
163// Ed25519 Keypair management
164// ---------------------------------------------------------------------------
165
166pub fn keypair_dir() -> PathBuf {
167    config::config_dir().join("auth")
168}
169
170fn private_key_path() -> PathBuf {
171    keypair_dir().join("ed25519.pem")
172}
173
174fn public_key_path() -> PathBuf {
175    keypair_dir().join("ed25519.pub.pem")
176}
177
178/// Derive a new Ed25519 keypair as PEM. Touches no filesystem state.
179/// Returns (private_pem, public_pem).
180pub fn generate_keypair_pem() -> Result<(String, String), AuthError> {
181    // jsonwebtoken's EncodingKey::from_ed_pem expects PKCS8 PEM.
182    let rng = ring::rand::SystemRandom::new();
183    let pkcs8_doc = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
184        .map_err(|e| AuthError::Other(format!("keypair generation failed: {}", e)))?;
185
186    let private_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", pkcs8_doc.as_ref()));
187
188    // Extract public key from the keypair.
189    let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8_doc.as_ref())
190        .map_err(|e| AuthError::Other(format!("keypair parse failed: {}", e)))?;
191    let pub_bytes = kp.public_key().as_ref();
192
193    // Wrap public key in SubjectPublicKeyInfo DER (for Ed25519 this is a fixed prefix + 32 bytes).
194    // OID 1.3.101.112 = id-EdDSA (Ed25519).
195    let mut spki = vec![
196        0x30, 0x2a, // SEQUENCE, 42 bytes total
197        0x30, 0x05, // SEQUENCE (AlgorithmIdentifier), 5 bytes
198        0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
199        0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused bits
200    ];
201    spki.extend_from_slice(pub_bytes);
202    let public_pem = pem::encode(&pem::Pem::new("PUBLIC KEY", spki));
203
204    Ok((private_pem, public_pem))
205}
206
207/// Generate a new Ed25519 keypair and write PEM files to the config dir.
208/// Returns (private_pem, public_pem).
209pub fn generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
210    let (private_pem, public_pem) = generate_keypair_pem()?;
211
212    let dir = keypair_dir();
213    fs::create_dir_all(&dir)?;
214
215    // Ensure the auth directory is gitignored — keys must never be committed.
216    let gitignore = dir.join(".gitignore");
217    if !gitignore.exists() {
218        let _ = fs::write(&gitignore, "*\n");
219    }
220
221    // Write key files with restrictive permissions set BEFORE writing content
222    // to avoid a window where the file exists with default (world-readable) mode.
223    #[cfg(unix)]
224    {
225        use std::fs::OpenOptions;
226        use std::io::Write;
227        use std::os::unix::fs::OpenOptionsExt;
228        use std::os::unix::fs::PermissionsExt;
229
230        let mut f = OpenOptions::new()
231            .write(true)
232            .create(true)
233            .truncate(true)
234            .mode(0o600)
235            .open(private_key_path())?;
236        f.write_all(private_pem.as_bytes())?;
237
238        let mut f = OpenOptions::new()
239            .write(true)
240            .create(true)
241            .truncate(true)
242            .mode(0o644)
243            .open(public_key_path())?;
244        f.write_all(public_pem.as_bytes())?;
245
246        let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
247    }
248
249    #[cfg(not(unix))]
250    {
251        fs::write(private_key_path(), &private_pem)?;
252        fs::write(public_key_path(), &public_pem)?;
253    }
254
255    Ok((private_pem.into_bytes(), public_pem.into_bytes()))
256}
257
258/// Load the Ed25519 keypair from disk. Returns (private_pem, public_pem).
259pub fn load_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
260    let priv_path = private_key_path();
261    let pub_path = public_key_path();
262
263    if !priv_path.exists() || !pub_path.exists() {
264        return Err(AuthError::NoKeypair);
265    }
266
267    let private_pem = fs::read(&priv_path)?;
268    let public_pem = fs::read(&pub_path)?;
269    Ok((private_pem, public_pem))
270}
271
272/// Load or generate the keypair. Generates if missing.
273pub fn load_or_generate_keypair() -> Result<(Vec<u8>, Vec<u8>), AuthError> {
274    match load_keypair() {
275        Ok(kp) => Ok(kp),
276        Err(AuthError::NoKeypair) => generate_keypair(),
277        Err(e) => Err(e),
278    }
279}
280
281// ---------------------------------------------------------------------------
282// JWT encode / decode
283// ---------------------------------------------------------------------------
284
285/// Mint a new access token.
286pub fn mint_access_token(
287    private_pem: &[u8],
288    user_id: i64,
289    username: &str,
290    role: Role,
291    ttl_secs: u64,
292) -> Result<String, AuthError> {
293    mint_access_token_with_role_str(private_pem, user_id, username, role.as_str(), ttl_secs)
294}
295
296/// Mint an access token carrying an arbitrary `role` claim.
297///
298/// The claim is a free-text string on the wire; this is the seam that lets the
299/// consumers of a token be tested against role values they cannot parse.
300pub fn mint_access_token_with_role_str(
301    private_pem: &[u8],
302    user_id: i64,
303    username: &str,
304    role: &str,
305    ttl_secs: u64,
306) -> Result<String, AuthError> {
307    let now = SystemTime::now()
308        .duration_since(UNIX_EPOCH)
309        .unwrap()
310        .as_secs();
311
312    let claims = Claims {
313        sub: user_id,
314        username: username.to_string(),
315        role: role.to_string(),
316        iat: now,
317        exp: now + ttl_secs,
318    };
319
320    let key = EncodingKey::from_ed_pem(private_pem)?;
321    let header = Header::new(Algorithm::EdDSA);
322    let token = jsonwebtoken::encode(&header, &claims, &key)?;
323    Ok(token)
324}
325
326/// Validate an access token and return its claims.
327pub fn validate_access_token(public_pem: &[u8], token: &str) -> Result<Claims, AuthError> {
328    let key = DecodingKey::from_ed_pem(public_pem)?;
329    let mut validation = Validation::new(Algorithm::EdDSA);
330    // Only require exp (expiry). sub and iat are custom fields, not JWT spec strings.
331    validation.set_required_spec_claims(&["exp"]);
332
333    let data = jsonwebtoken::decode::<Claims>(token, &key, &validation)?;
334    Ok(data.claims)
335}
336
337// ---------------------------------------------------------------------------
338// Time helpers
339// ---------------------------------------------------------------------------
340
341pub fn now_unix() -> u64 {
342    SystemTime::now()
343        .duration_since(UNIX_EPOCH)
344        .unwrap()
345        .as_secs()
346}
347
348/// Parse a duration string like "15m", "7d", "24h", "3600s" into seconds.
349pub fn parse_duration_secs(s: &str) -> Option<u64> {
350    let s = s.trim();
351    if s.is_empty() {
352        return None;
353    }
354
355    let (num_str, multiplier) = if let Some(n) = s.strip_suffix('d') {
356        (n, 86400)
357    } else if let Some(n) = s.strip_suffix('h') {
358        (n, 3600)
359    } else if let Some(n) = s.strip_suffix('m') {
360        (n, 60)
361    } else if let Some(n) = s.strip_suffix('s') {
362        (n, 1)
363    } else {
364        (s, 1)
365    };
366
367    let num: u64 = num_str.parse().ok()?;
368    Some(num * multiplier)
369}
370
371// ---------------------------------------------------------------------------
372// Tests
373// ---------------------------------------------------------------------------
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn password_hash_and_verify() {
381        let password = "hunter2";
382        let hash = hash_password(password).unwrap();
383        assert!(hash.starts_with("$argon2"));
384        verify_password(password, &hash).unwrap();
385    }
386
387    #[test]
388    fn password_verify_wrong() {
389        let hash = hash_password("correct").unwrap();
390        let result = verify_password("wrong", &hash);
391        assert!(matches!(result, Err(AuthError::InvalidPassword)));
392    }
393
394    /// Hashed by argon2 0.5. Every stored password was, so this is what a
395    /// dependency bump must never stop accepting.
396    #[test]
397    fn password_verify_hash_from_argon2_0_5() {
398        let hash = "$argon2id$v=19$m=19456,t=2,p=1$M/zwWdjjbwOvNCjzP+5t5A$pflXrbL1iOYPBlbgtK59wr2PkBaH7UVLKoBisvJ+Yfk";
399        verify_password("correct horse", hash).unwrap();
400        assert!(matches!(
401            verify_password("wrong horse", hash),
402            Err(AuthError::InvalidPassword)
403        ));
404    }
405
406    #[test]
407    fn keypair_generate_and_jwt_roundtrip() {
408        let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
409
410        let token =
411            mint_access_token(priv_pem.as_bytes(), 42, "testuser", Role::Admin, 3600).unwrap();
412        let claims = validate_access_token(pub_pem.as_bytes(), &token).unwrap();
413
414        assert_eq!(claims.sub, 42);
415        assert_eq!(claims.username, "testuser");
416        assert_eq!(claims.role, "admin");
417    }
418
419    #[test]
420    fn expired_token_rejected() {
421        let (priv_pem, pub_pem) = generate_keypair_pem().unwrap();
422        // Manually create a token that expired 10 minutes ago.
423        let now = std::time::SystemTime::now()
424            .duration_since(std::time::UNIX_EPOCH)
425            .unwrap()
426            .as_secs();
427        let claims = Claims {
428            sub: 1,
429            username: "user".into(),
430            role: "user".into(),
431            iat: now - 1200,
432            exp: now - 600, // expired 10 min ago
433        };
434        let key = jsonwebtoken::EncodingKey::from_ed_pem(priv_pem.as_bytes()).unwrap();
435        let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
436        let token = jsonwebtoken::encode(&header, &claims, &key).unwrap();
437        let result = validate_access_token(pub_pem.as_bytes(), &token);
438        assert!(result.is_err());
439    }
440
441    #[test]
442    fn role_permissions() {
443        assert!(Role::Admin.has_permission(Role::Admin));
444        assert!(Role::Admin.has_permission(Role::User));
445        assert!(Role::Admin.has_permission(Role::Readonly));
446
447        assert!(!Role::User.has_permission(Role::Admin));
448        assert!(Role::User.has_permission(Role::User));
449        assert!(Role::User.has_permission(Role::Readonly));
450
451        assert!(!Role::Readonly.has_permission(Role::Admin));
452        assert!(!Role::Readonly.has_permission(Role::User));
453        assert!(Role::Readonly.has_permission(Role::Readonly));
454    }
455
456    #[test]
457    fn parse_duration() {
458        assert_eq!(parse_duration_secs("15m"), Some(900));
459        assert_eq!(parse_duration_secs("7d"), Some(604800));
460        assert_eq!(parse_duration_secs("24h"), Some(86400));
461        assert_eq!(parse_duration_secs("3600s"), Some(3600));
462        assert_eq!(parse_duration_secs("3600"), Some(3600));
463        assert_eq!(parse_duration_secs(""), None);
464    }
465}