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
17pub const ANONYMOUS: &str = "anonymous";
21
22#[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#[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 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#[derive(Debug, Serialize, Deserialize)]
98pub struct Claims {
99 pub sub: i64,
101 pub username: String,
103 pub role: String,
105 pub iat: u64,
107 pub exp: u64,
109}
110
111pub 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
126pub 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
138pub 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
157pub 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
170pub 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
180fn subsonic_key_path() -> PathBuf {
195 keypair_dir().join("subsonic.key")
196}
197
198pub 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 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
244pub 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
265pub 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
293pub fn generate_keypair_pem() -> Result<(String, String), AuthError> {
296 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 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 let mut spki = vec![
311 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, ];
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
322pub 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 let gitignore = dir.join(".gitignore");
332 if !gitignore.exists() {
333 let _ = fs::write(&gitignore, "*\n");
334 }
335
336 #[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
373pub 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
387pub 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
396pub 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
424pub 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 validation.set_required_spec_claims(&["exp"]);
430
431 let data = jsonwebtoken::decode::<Claims>(token, &key, &validation)?;
432 Ok(data.claims)
433}
434
435pub fn now_unix() -> u64 {
440 SystemTime::now()
441 .duration_since(UNIX_EPOCH)
442 .unwrap()
443 .as_secs()
444}
445
446pub 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#[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 #[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 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, };
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}