1use 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#[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#[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 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#[derive(Debug, Serialize, Deserialize)]
93pub struct Claims {
94 pub sub: i64,
96 pub username: String,
98 pub role: String,
100 pub iat: u64,
102 pub exp: u64,
104}
105
106pub 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
121pub 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
133pub 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
152pub 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
165pub 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
175fn subsonic_key_path() -> PathBuf {
190 keypair_dir().join("subsonic.key")
191}
192
193pub 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 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
239pub 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
260pub 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
288pub fn generate_keypair_pem() -> Result<(String, String), AuthError> {
291 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 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 let mut spki = vec![
306 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, ];
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
317pub 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 let gitignore = dir.join(".gitignore");
327 if !gitignore.exists() {
328 let _ = fs::write(&gitignore, "*\n");
329 }
330
331 #[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
368pub 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
382pub 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
391pub 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
406pub 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
436pub 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 validation.set_required_spec_claims(&["exp"]);
442
443 let data = jsonwebtoken::decode::<Claims>(token, &key, &validation)?;
444 Ok(data.claims)
445}
446
447pub fn now_unix() -> u64 {
452 SystemTime::now()
453 .duration_since(UNIX_EPOCH)
454 .unwrap()
455 .as_secs()
456}
457
458pub 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#[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 #[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 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, };
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}