1use crate::hashing::sha256_prefixed;
2#[cfg(feature = "auth")]
3use crate::trust::EmailConfirmationPolicy;
4use crate::{PrayError, PrayResult};
5#[cfg(feature = "auth")]
6use base64::{engine::general_purpose::STANDARD, Engine as _};
7#[cfg(feature = "auth")]
8use ed25519_dalek::{Signature, Verifier, VerifyingKey};
9#[cfg(feature = "auth")]
10use rusqlite::{Connection, OptionalExtension};
11use serde::{Deserialize, Serialize};
12#[cfg(feature = "auth")]
13use std::fs;
14#[cfg(feature = "auth")]
15use std::path::{Path, PathBuf};
16#[cfg(feature = "auth")]
17use std::time::{SystemTime, UNIX_EPOCH};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct AuthRegistrationRequest {
21 pub email: String,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct AuthVerificationRequest {
26 pub email: String,
27 pub code: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct AuthSessionRequest {
32 pub email: String,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct AuthPasskeyEnrollmentRequest {
37 pub email: String,
38 pub credential_id: String,
39 pub public_key: String,
40 #[serde(default)]
41 pub label: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct AuthPasskeyChallengeRequest {
46 pub credential_id: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct AuthPasskeyChallengeResponse {
51 pub credential_id: String,
52 pub challenge_id: String,
53 pub challenge: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct AuthPasskeyLoginRequest {
58 pub credential_id: String,
59 pub challenge_id: String,
60 pub signature: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct AuthSshKeyEnrollmentRequest {
65 pub email: String,
66 pub public_key: String,
67 #[serde(default)]
68 pub label: Option<String>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct AuthSshKeyChallengeRequest {
73 pub public_key: String,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct AuthSshKeyChallengeResponse {
78 pub fingerprint: String,
79 pub challenge_id: String,
80 pub challenge: String,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct AuthSshKeyLoginRequest {
85 pub public_key: String,
86 pub challenge_id: String,
87 pub signature: String,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct AuthRegistrationResponse {
92 pub email: String,
93 pub verified: bool,
94 #[serde(default)]
95 pub verification_code: Option<String>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct AuthVerificationResponse {
100 pub email: String,
101 pub verified: bool,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum AuthSessionKind {
107 Email,
108 Passkey,
109 SshKey,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct AuthSessionResponse {
114 pub email: String,
115 pub token: String,
116 pub kind: AuthSessionKind,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct AuthPasskeyEnrollmentResponse {
121 pub email: String,
122 pub credential_id: String,
123 pub enrolled: bool,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct AuthPasskeyLoginResponse {
128 pub email: String,
129 pub token: String,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct AuthChallengeResponse {
134 pub challenge_id: String,
135 pub challenge: String,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct AuthSshKeyEnrollmentResponse {
140 pub email: String,
141 pub fingerprint: String,
142 pub enrolled: bool,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct AuthSshKeyLoginResponse {
147 pub email: String,
148 pub token: String,
149}
150
151#[cfg(feature = "auth")]
152mod store {
153 use super::*;
154
155 #[derive(Debug, Clone)]
156 pub struct RegistryAuthStore {
157 database_path: PathBuf,
158 }
159
160 #[derive(Debug, Clone)]
161 struct StoredChallenge {
162 challenge: String,
163 }
164
165 impl RegistryAuthStore {
166 pub fn open(root: &Path) -> PrayResult<Self> {
167 let database_path = root.join(".pray/auth.db");
168 if let Some(parent) = database_path.parent() {
169 fs::create_dir_all(parent)?;
170 }
171 let store = Self { database_path };
172 store.initialize()?;
173 Ok(store)
174 }
175
176 pub fn register_email(
177 &self,
178 email: &str,
179 policy: EmailConfirmationPolicy,
180 ) -> PrayResult<AuthRegistrationResponse> {
181 validate_email(email)?;
182 let connection = self.connection()?;
183 let timestamp = current_unix_timestamp()?;
184 let verified = matches!(policy, EmailConfirmationPolicy::Disabled);
185 let verification_code = if verified {
186 None
187 } else {
188 Some(generate_verification_code(email, timestamp))
189 };
190 let policy_text = email_confirmation_policy_text(policy);
191
192 connection.execute(
193 "INSERT INTO users (email, email_verified, email_confirmation_policy, created_at) VALUES (?1, ?2, ?3, ?4)
194 ON CONFLICT(email) DO UPDATE SET email_verified = excluded.email_verified, email_confirmation_policy = excluded.email_confirmation_policy",
195 rusqlite::params![email, verified, policy_text, timestamp],
196 )?;
197 if let Some(code) = verification_code.as_ref() {
198 connection.execute(
199 "INSERT INTO email_verification_codes (email, code, created_at, verified_at)
200 VALUES (?1, ?2, ?3, NULL)
201 ON CONFLICT(email) DO UPDATE SET code = excluded.code, created_at = excluded.created_at, verified_at = NULL",
202 rusqlite::params![email, code, timestamp],
203 )?;
204 }
205
206 Ok(AuthRegistrationResponse {
207 email: email.to_string(),
208 verified,
209 verification_code,
210 })
211 }
212
213 pub fn verify_email(
214 &self,
215 email: &str,
216 code: &str,
217 ) -> PrayResult<AuthVerificationResponse> {
218 validate_email(email)?;
219 if code.trim().is_empty() {
220 return Err(PrayError::Unsupported(
221 "verification code cannot be empty".to_string(),
222 ));
223 }
224 let connection = self.connection()?;
225 let stored_code: Option<String> = connection
226 .query_row(
227 "SELECT code FROM email_verification_codes WHERE email = ?1",
228 rusqlite::params![email],
229 |row| row.get(0),
230 )
231 .optional()?;
232 let Some(stored_code) = stored_code else {
233 return Err(PrayError::Resolution(format!(
234 "no verification code found for {email}"
235 )));
236 };
237 if stored_code != code {
238 return Err(PrayError::Resolution(format!(
239 "verification code mismatch for {email}"
240 )));
241 }
242 let timestamp = current_unix_timestamp()?;
243 connection.execute(
244 "UPDATE users SET email_verified = 1 WHERE email = ?1",
245 rusqlite::params![email],
246 )?;
247 connection.execute(
248 "UPDATE email_verification_codes SET verified_at = ?2 WHERE email = ?1",
249 rusqlite::params![email, timestamp],
250 )?;
251 Ok(AuthVerificationResponse {
252 email: email.to_string(),
253 verified: true,
254 })
255 }
256
257 pub fn user_verified(&self, email: &str) -> PrayResult<bool> {
258 validate_email(email)?;
259 let connection = self.connection()?;
260 let verified: Option<bool> = connection
261 .query_row(
262 "SELECT email_verified FROM users WHERE email = ?1",
263 rusqlite::params![email],
264 |row| row.get(0),
265 )
266 .optional()?;
267 Ok(verified.unwrap_or(false))
268 }
269
270 pub fn request_passkey_challenge(
271 &self,
272 credential_id: &str,
273 ) -> PrayResult<AuthPasskeyChallengeResponse> {
274 validate_identifier(credential_id, "credential id")?;
275 let connection = self.connection()?;
276 let email: String = connection.query_row(
277 "SELECT email FROM passkeys WHERE credential_id = ?1",
278 rusqlite::params![credential_id],
279 |row| row.get(0),
280 )?;
281 let challenge = generate_auth_challenge("passkey", credential_id)?;
282 let challenge_id = generate_challenge_id(&email, credential_id, "passkey", &challenge)?;
283 store_challenge(&connection, &challenge_id, &email, &challenge, "passkey")?;
284 Ok(AuthPasskeyChallengeResponse {
285 credential_id: credential_id.to_string(),
286 challenge_id,
287 challenge,
288 })
289 }
290
291 pub fn respond_passkey_challenge(
292 &self,
293 credential_id: &str,
294 challenge_id: &str,
295 signature: &str,
296 ) -> PrayResult<AuthPasskeyLoginResponse> {
297 validate_identifier(credential_id, "credential id")?;
298 validate_identifier(challenge_id, "challenge id")?;
299 validate_signature(signature)?;
300 let connection = self.connection()?;
301 let email: String = connection.query_row(
302 "SELECT email FROM passkeys WHERE credential_id = ?1",
303 rusqlite::params![credential_id],
304 |row| row.get(0),
305 )?;
306 let challenge = load_challenge(&connection, challenge_id, &email, "passkey")?;
307 let public_key = load_passkey_public_key(&connection, credential_id)?;
308 verify_signature(&public_key, challenge.challenge.as_bytes(), signature)?;
309 mark_challenge_used(&connection, challenge_id)?;
310 let session = self.issue_session(&email, AuthSessionKind::Passkey)?;
311 Ok(AuthPasskeyLoginResponse {
312 email,
313 token: session.token,
314 })
315 }
316
317 pub fn request_ssh_key_challenge(
318 &self,
319 public_key: &str,
320 ) -> PrayResult<AuthSshKeyChallengeResponse> {
321 validate_public_key(public_key)?;
322 let connection = self.connection()?;
323 let (public_key, _) = parse_ssh_ed25519_public_key(public_key)?;
324 let fingerprint = ssh_key_fingerprint(&public_key);
325 let email: String = connection.query_row(
326 "SELECT email FROM ssh_keys WHERE fingerprint = ?1",
327 rusqlite::params![fingerprint],
328 |row| row.get(0),
329 )?;
330 let challenge = generate_auth_challenge("ssh_key", &public_key)?;
331 let challenge_id = generate_challenge_id(&email, &fingerprint, "ssh_key", &challenge)?;
332 store_challenge(&connection, &challenge_id, &email, &challenge, "ssh_key")?;
333 Ok(AuthSshKeyChallengeResponse {
334 fingerprint,
335 challenge_id,
336 challenge,
337 })
338 }
339
340 pub fn respond_ssh_key_challenge(
341 &self,
342 public_key: &str,
343 challenge_id: &str,
344 signature: &str,
345 ) -> PrayResult<AuthSshKeyLoginResponse> {
346 validate_public_key(public_key)?;
347 validate_identifier(challenge_id, "challenge id")?;
348 validate_signature(signature)?;
349 let connection = self.connection()?;
350 let (public_key, _) = parse_ssh_ed25519_public_key(public_key)?;
351 let fingerprint = ssh_key_fingerprint(&public_key);
352 let email: String = connection.query_row(
353 "SELECT email FROM ssh_keys WHERE fingerprint = ?1",
354 rusqlite::params![fingerprint],
355 |row| row.get(0),
356 )?;
357 let challenge = load_challenge(&connection, challenge_id, &email, "ssh_key")?;
358 verify_signature(&public_key, challenge.challenge.as_bytes(), signature)?;
359 mark_challenge_used(&connection, challenge_id)?;
360 let session = self.issue_session(&email, AuthSessionKind::SshKey)?;
361 Ok(AuthSshKeyLoginResponse {
362 email,
363 token: session.token,
364 })
365 }
366
367 pub fn issue_session(
368 &self,
369 email: &str,
370 kind: AuthSessionKind,
371 ) -> PrayResult<AuthSessionResponse> {
372 validate_email(email)?;
373 let connection = self.connection()?;
374 let user: Option<(bool, String)> = connection
375 .query_row(
376 "SELECT email_verified, email_confirmation_policy FROM users WHERE email = ?1",
377 rusqlite::params![email],
378 |row| Ok((row.get(0)?, row.get(1)?)),
379 )
380 .optional()?;
381 let Some((verified, policy)) = user else {
382 return Err(PrayError::Resolution(format!("unknown user: {email}")));
383 };
384 if !verified
385 && policy != email_confirmation_policy_text(EmailConfirmationPolicy::Optional)
386 {
387 return Err(PrayError::Resolution(format!(
388 "email confirmation required for {email}"
389 )));
390 }
391 let timestamp = current_unix_timestamp()?;
392 let token = generate_session_token(email, &kind, timestamp);
393 connection.execute(
394 "INSERT INTO sessions (token, email, kind, created_at, last_used_at)
395 VALUES (?1, ?2, ?3, ?4, ?4)
396 ON CONFLICT(token) DO UPDATE SET last_used_at = excluded.last_used_at",
397 rusqlite::params![token, email, auth_session_kind_text(&kind), timestamp],
398 )?;
399 Ok(AuthSessionResponse {
400 email: email.to_string(),
401 token,
402 kind,
403 })
404 }
405
406 pub fn resolve_session(&self, token: &str) -> PrayResult<Option<AuthSessionResponse>> {
407 if token.trim().is_empty() {
408 return Ok(None);
409 }
410 let connection = self.connection()?;
411 let session: Option<(String, String)> = connection
412 .query_row(
413 "SELECT email, kind FROM sessions WHERE token = ?1",
414 rusqlite::params![token],
415 |row| Ok((row.get(0)?, row.get(1)?)),
416 )
417 .optional()?;
418 let Some((email, kind_text)) = session else {
419 return Ok(None);
420 };
421 let kind = parse_auth_session_kind(&kind_text)?;
422 let timestamp = current_unix_timestamp()?;
423 connection.execute(
424 "UPDATE sessions SET last_used_at = ?2 WHERE token = ?1",
425 rusqlite::params![token, timestamp],
426 )?;
427 Ok(Some(AuthSessionResponse {
428 email,
429 token: token.to_string(),
430 kind,
431 }))
432 }
433
434 pub fn enroll_passkey(
435 &self,
436 email: &str,
437 credential_id: &str,
438 public_key: &str,
439 label: Option<&str>,
440 ) -> PrayResult<AuthPasskeyEnrollmentResponse> {
441 validate_email(email)?;
442 validate_identifier(credential_id, "credential id")?;
443 validate_public_key(public_key)?;
444 let connection = self.connection()?;
445 ensure_user_can_authenticate(&connection, email)?;
446 let timestamp = current_unix_timestamp()?;
447 connection.execute(
448 "INSERT INTO passkeys (credential_id, email, public_key, label, created_at, last_used_at)
449 VALUES (?1, ?2, ?3, ?4, ?5, NULL)
450 ON CONFLICT(credential_id) DO UPDATE SET email = excluded.email, public_key = excluded.public_key, label = excluded.label",
451 rusqlite::params![credential_id, email, public_key, label.unwrap_or(""), timestamp],
452 )?;
453 Ok(AuthPasskeyEnrollmentResponse {
454 email: email.to_string(),
455 credential_id: credential_id.to_string(),
456 enrolled: true,
457 })
458 }
459
460 pub fn login_with_passkey(
461 &self,
462 credential_id: &str,
463 ) -> PrayResult<AuthPasskeyLoginResponse> {
464 validate_identifier(credential_id, "credential id")?;
465 let connection = self.connection()?;
466 let email: Option<String> = connection
467 .query_row(
468 "SELECT email FROM passkeys WHERE credential_id = ?1",
469 rusqlite::params![credential_id],
470 |row| row.get(0),
471 )
472 .optional()?;
473 let Some(email) = email else {
474 return Err(PrayError::Resolution(format!(
475 "unknown passkey credential: {credential_id}"
476 )));
477 };
478 let session = self.issue_session(&email, AuthSessionKind::Passkey)?;
479 connection.execute(
480 "UPDATE passkeys SET last_used_at = ?2 WHERE credential_id = ?1",
481 rusqlite::params![credential_id, current_unix_timestamp()?],
482 )?;
483 Ok(AuthPasskeyLoginResponse {
484 email,
485 token: session.token,
486 })
487 }
488
489 pub fn enroll_ssh_key(
490 &self,
491 email: &str,
492 public_key: &str,
493 label: Option<&str>,
494 ) -> PrayResult<AuthSshKeyEnrollmentResponse> {
495 validate_email(email)?;
496 validate_public_key(public_key)?;
497 let connection = self.connection()?;
498 ensure_user_can_authenticate(&connection, email)?;
499 let (public_key, _) = parse_ssh_ed25519_public_key(public_key)?;
500 let fingerprint = ssh_key_fingerprint(&public_key);
501 let timestamp = current_unix_timestamp()?;
502 connection.execute(
503 "INSERT INTO ssh_keys (fingerprint, email, public_key, label, created_at, last_used_at)
504 VALUES (?1, ?2, ?3, ?4, ?5, NULL)
505 ON CONFLICT(fingerprint) DO UPDATE SET email = excluded.email, public_key = excluded.public_key, label = excluded.label",
506 rusqlite::params![fingerprint, email, public_key, label.unwrap_or(""), timestamp],
507 )?;
508 Ok(AuthSshKeyEnrollmentResponse {
509 email: email.to_string(),
510 fingerprint,
511 enrolled: true,
512 })
513 }
514
515 pub fn login_with_ssh_key(&self, public_key: &str) -> PrayResult<AuthSshKeyLoginResponse> {
516 validate_public_key(public_key)?;
517 let connection = self.connection()?;
518 let (public_key, _) = parse_ssh_ed25519_public_key(public_key)?;
519 let fingerprint = ssh_key_fingerprint(&public_key);
520 let email: Option<String> = connection
521 .query_row(
522 "SELECT email FROM ssh_keys WHERE fingerprint = ?1",
523 rusqlite::params![fingerprint],
524 |row| row.get(0),
525 )
526 .optional()?;
527 let Some(email) = email else {
528 return Err(PrayError::Resolution(format!(
529 "unknown ssh key fingerprint: {fingerprint}"
530 )));
531 };
532 let session = self.issue_session(&email, AuthSessionKind::SshKey)?;
533 connection.execute(
534 "UPDATE ssh_keys SET last_used_at = ?2 WHERE fingerprint = ?1",
535 rusqlite::params![fingerprint, current_unix_timestamp()?],
536 )?;
537 Ok(AuthSshKeyLoginResponse {
538 email,
539 token: session.token,
540 })
541 }
542
543 fn initialize(&self) -> PrayResult<()> {
544 let connection = self.connection()?;
545 connection.execute_batch(
546 "CREATE TABLE IF NOT EXISTS users (
547 email TEXT PRIMARY KEY,
548 email_verified INTEGER NOT NULL,
549 email_confirmation_policy TEXT NOT NULL,
550 created_at INTEGER NOT NULL
551 );
552 CREATE TABLE IF NOT EXISTS email_verification_codes (
553 email TEXT PRIMARY KEY,
554 code TEXT NOT NULL,
555 created_at INTEGER NOT NULL,
556 verified_at INTEGER
557 );
558 CREATE TABLE IF NOT EXISTS passkeys (
559 credential_id TEXT PRIMARY KEY,
560 email TEXT NOT NULL,
561 public_key TEXT NOT NULL,
562 label TEXT,
563 created_at INTEGER NOT NULL,
564 last_used_at INTEGER,
565 FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
566 );
567 CREATE TABLE IF NOT EXISTS ssh_keys (
568 fingerprint TEXT PRIMARY KEY,
569 email TEXT NOT NULL,
570 public_key TEXT NOT NULL,
571 label TEXT,
572 created_at INTEGER NOT NULL,
573 last_used_at INTEGER,
574 FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
575 );
576 CREATE TABLE IF NOT EXISTS sessions (
577 token TEXT PRIMARY KEY,
578 email TEXT NOT NULL,
579 kind TEXT NOT NULL,
580 created_at INTEGER NOT NULL,
581 last_used_at INTEGER,
582 FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
583 );
584 CREATE TABLE IF NOT EXISTS auth_challenges (
585 challenge_id TEXT PRIMARY KEY,
586 email TEXT NOT NULL,
587 kind TEXT NOT NULL,
588 challenge TEXT NOT NULL,
589 created_at INTEGER NOT NULL,
590 used_at INTEGER,
591 FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
592 );",
593 )?;
594 Ok(())
595 }
596
597 fn connection(&self) -> PrayResult<Connection> {
598 let connection = Connection::open(&self.database_path)?;
599 connection.pragma_update(None, "foreign_keys", "ON")?;
600 Ok(connection)
601 }
602 }
603
604 fn validate_email(email: &str) -> PrayResult<()> {
605 let email = email.trim();
606 if email.is_empty()
607 || !email.contains('@')
608 || email.starts_with('@')
609 || email.ends_with('@')
610 {
611 return Err(PrayError::Unsupported(
612 "email must be a non-empty address".to_string(),
613 ));
614 }
615 Ok(())
616 }
617
618 fn validate_identifier(value: &str, label: &str) -> PrayResult<()> {
619 if value.trim().is_empty() {
620 return Err(PrayError::Unsupported(format!("{label} cannot be empty")));
621 }
622 Ok(())
623 }
624
625 fn validate_public_key(public_key: &str) -> PrayResult<()> {
626 let public_key = public_key.trim();
627 if public_key.is_empty() {
628 return Err(PrayError::Unsupported(
629 "public key cannot be empty".to_string(),
630 ));
631 }
632 Ok(())
633 }
634
635 fn ensure_user_can_authenticate(connection: &Connection, email: &str) -> PrayResult<()> {
636 let user: Option<(bool, String)> = connection
637 .query_row(
638 "SELECT email_verified, email_confirmation_policy FROM users WHERE email = ?1",
639 rusqlite::params![email],
640 |row| Ok((row.get(0)?, row.get(1)?)),
641 )
642 .optional()?;
643 let Some((verified, policy)) = user else {
644 return Err(PrayError::Resolution(format!("unknown user: {email}")));
645 };
646 if verified || policy == email_confirmation_policy_text(EmailConfirmationPolicy::Optional) {
647 Ok(())
648 } else {
649 Err(PrayError::Resolution(format!(
650 "email confirmation required for {email}"
651 )))
652 }
653 }
654
655 fn email_confirmation_policy_text(policy: EmailConfirmationPolicy) -> &'static str {
656 match policy {
657 EmailConfirmationPolicy::Required => "required",
658 EmailConfirmationPolicy::Optional => "optional",
659 EmailConfirmationPolicy::Disabled => "disabled",
660 }
661 }
662
663 fn auth_session_kind_text(kind: &AuthSessionKind) -> &'static str {
664 match kind {
665 AuthSessionKind::Email => "email",
666 AuthSessionKind::Passkey => "passkey",
667 AuthSessionKind::SshKey => "ssh_key",
668 }
669 }
670
671 fn parse_auth_session_kind(kind: &str) -> PrayResult<AuthSessionKind> {
672 match kind {
673 "email" => Ok(AuthSessionKind::Email),
674 "passkey" => Ok(AuthSessionKind::Passkey),
675 "ssh_key" => Ok(AuthSessionKind::SshKey),
676 other => Err(PrayError::Resolution(format!(
677 "unknown auth session kind: {other}"
678 ))),
679 }
680 }
681
682 fn current_unix_timestamp() -> PrayResult<u64> {
683 SystemTime::now()
684 .duration_since(UNIX_EPOCH)
685 .map_err(|error| PrayError::Resolution(error.to_string()))
686 .map(|duration| duration.as_secs())
687 }
688
689 fn generate_auth_challenge(kind: &str, subject: &str) -> PrayResult<String> {
690 let timestamp = current_unix_timestamp()?;
691 Ok(sha256_prefixed(
692 format!("{kind}\0{subject}\0{timestamp}").as_bytes(),
693 ))
694 }
695
696 fn generate_challenge_id(
697 email: &str,
698 subject: &str,
699 kind: &str,
700 challenge: &str,
701 ) -> PrayResult<String> {
702 Ok(sha256_prefixed(
703 format!("challenge\0{email}\0{subject}\0{kind}\0{challenge}").as_bytes(),
704 ))
705 }
706
707 fn store_challenge(
708 connection: &Connection,
709 challenge_id: &str,
710 email: &str,
711 challenge: &str,
712 kind: &str,
713 ) -> PrayResult<()> {
714 let timestamp = current_unix_timestamp()?;
715 connection.execute(
716 "INSERT INTO auth_challenges (challenge_id, email, kind, challenge, created_at, used_at)
717 VALUES (?1, ?2, ?3, ?4, ?5, NULL)
718 ON CONFLICT(challenge_id) DO UPDATE SET email = excluded.email, kind = excluded.kind, challenge = excluded.challenge, created_at = excluded.created_at, used_at = NULL",
719 rusqlite::params![challenge_id, email, kind, challenge, timestamp],
720 )?;
721 Ok(())
722 }
723
724 fn load_challenge(
725 connection: &Connection,
726 challenge_id: &str,
727 email: &str,
728 kind: &str,
729 ) -> PrayResult<StoredChallenge> {
730 let challenge: Option<StoredChallenge> = connection
731 .query_row(
732 "SELECT challenge FROM auth_challenges WHERE challenge_id = ?1 AND email = ?2 AND kind = ?3 AND used_at IS NULL",
733 rusqlite::params![challenge_id, email, kind],
734 |row| Ok(StoredChallenge { challenge: row.get(0)? }),
735 )
736 .optional()?;
737 challenge.ok_or_else(|| PrayError::Resolution(format!("challenge not found for {email}")))
738 }
739
740 fn mark_challenge_used(connection: &Connection, challenge_id: &str) -> PrayResult<()> {
741 let timestamp = current_unix_timestamp()?;
742 connection.execute(
743 "UPDATE auth_challenges SET used_at = ?2 WHERE challenge_id = ?1",
744 rusqlite::params![challenge_id, timestamp],
745 )?;
746 Ok(())
747 }
748
749 fn load_passkey_public_key(connection: &Connection, credential_id: &str) -> PrayResult<String> {
750 let public_key: String = connection.query_row(
751 "SELECT public_key FROM passkeys WHERE credential_id = ?1",
752 rusqlite::params![credential_id],
753 |row| row.get(0),
754 )?;
755 Ok(public_key)
756 }
757
758 fn validate_signature(signature: &str) -> PrayResult<()> {
759 if signature.trim().is_empty() {
760 return Err(PrayError::Unsupported(
761 "signature cannot be empty".to_string(),
762 ));
763 }
764 Ok(())
765 }
766
767 fn verify_signature(public_key: &str, message: &[u8], signature: &str) -> PrayResult<()> {
768 let (_, key_bytes) = parse_ssh_ed25519_public_key(public_key)?;
769 let verifying_key =
770 VerifyingKey::from_bytes(&key_bytes).map_err(|error| PrayError::Parse {
771 kind: "public key",
772 message: error.to_string(),
773 })?;
774 let signature_bytes =
775 STANDARD
776 .decode(signature.as_bytes())
777 .map_err(|error| PrayError::Parse {
778 kind: "signature",
779 message: error.to_string(),
780 })?;
781 let signature = Signature::from_slice(&signature_bytes)
782 .map_err(|error| PrayError::Verify(error.to_string()))?;
783 verifying_key
784 .verify(message, &signature)
785 .map_err(|error| PrayError::Verify(error.to_string()))
786 }
787
788 fn parse_ssh_ed25519_public_key(public_key: &str) -> PrayResult<(String, [u8; 32])> {
789 let mut fields = public_key.split_whitespace();
790 let algorithm = fields.next().ok_or_else(|| {
791 PrayError::Unsupported("public key must include an algorithm".to_string())
792 })?;
793 if algorithm != "ssh-ed25519" {
794 return Err(PrayError::Unsupported(format!(
795 "unsupported public key algorithm: {algorithm}"
796 )));
797 }
798 let key_value = fields.next().ok_or_else(|| {
799 PrayError::Unsupported("public key must include key bytes".to_string())
800 })?;
801 let blob = STANDARD
802 .decode(key_value.as_bytes())
803 .map_err(|error| PrayError::Parse {
804 kind: "public key",
805 message: error.to_string(),
806 })?;
807 let mut cursor = blob.as_slice();
808 let blob_algorithm = read_ssh_string(&mut cursor)?;
809 if blob_algorithm != b"ssh-ed25519" {
810 return Err(PrayError::Parse {
811 kind: "public key",
812 message: "ed25519 public key blob must start with ssh-ed25519".to_string(),
813 });
814 }
815 let key_bytes = read_ssh_string(&mut cursor)?;
816 let key_bytes: [u8; 32] =
817 key_bytes
818 .as_slice()
819 .try_into()
820 .map_err(|_| PrayError::Parse {
821 kind: "public key",
822 message: "ed25519 public key must be 32 bytes".to_string(),
823 })?;
824 Ok((format!("ssh-ed25519 {key_value}"), key_bytes))
825 }
826
827 fn read_ssh_string(cursor: &mut &[u8]) -> PrayResult<Vec<u8>> {
828 let length = read_u32_from_slice(cursor)? as usize;
829 if cursor.len() < length {
830 return Err(PrayError::Resolution(
831 "truncated ssh public key blob".to_string(),
832 ));
833 }
834 let (value, rest) = cursor.split_at(length);
835 *cursor = rest;
836 Ok(value.to_vec())
837 }
838
839 fn read_u32_from_slice(cursor: &mut &[u8]) -> PrayResult<u32> {
840 if cursor.len() < 4 {
841 return Err(PrayError::Resolution("truncated ssh field".to_string()));
842 }
843 let (length_bytes, rest) = cursor.split_at(4);
844 *cursor = rest;
845 Ok(u32::from_be_bytes(
846 length_bytes.try_into().expect("length bytes"),
847 ))
848 }
849
850 fn generate_verification_code(email: &str, timestamp: u64) -> String {
851 let payload = format!("{email}\0{timestamp}");
852 let hash = sha256_prefixed(payload.as_bytes());
853 let hex = hash.trim_start_matches("sha256:");
854 let numeric = u32::from_str_radix(&hex[..8], 16).unwrap_or(0) % 1_000_000;
855 format!("{:06}", numeric)
856 }
857
858 fn generate_session_token(email: &str, kind: &AuthSessionKind, timestamp: u64) -> String {
859 let payload = format!("{email}\0{}\0{timestamp}", auth_session_kind_text(kind));
860 sha256_prefixed(payload.as_bytes())
861 }
862
863 pub fn ssh_public_key_fingerprint_text(public_key: &str) -> PrayResult<String> {
864 let (canonical, _) = parse_ssh_ed25519_public_key(public_key)?;
865 Ok(normalize_ssh_fingerprint(&ssh_key_fingerprint(&canonical)))
866 }
867
868 fn normalize_ssh_fingerprint(fingerprint: &str) -> String {
869 fingerprint.trim().to_ascii_uppercase()
870 }
871
872 fn ssh_key_fingerprint(public_key: &str) -> String {
873 sha256_prefixed(public_key.as_bytes())
874 }
875}
876
877#[cfg(not(feature = "auth"))]
878pub fn ssh_public_key_fingerprint_text(public_key: &str) -> PrayResult<String> {
879 let mut fields = public_key.split_whitespace();
880 let algorithm = fields.next().ok_or_else(|| PrayError::Parse {
881 kind: "public key",
882 message: "public key must include an algorithm".to_string(),
883 })?;
884 let encoded_key = fields.next().ok_or_else(|| PrayError::Parse {
885 kind: "public key",
886 message: "public key must include key bytes".to_string(),
887 })?;
888 if algorithm != "ssh-ed25519" {
889 return Err(PrayError::Unsupported(format!(
890 "unsupported public key algorithm: {algorithm}"
891 )));
892 }
893
894 Ok(sha256_prefixed(format!("{algorithm} {encoded_key}").as_bytes()).to_ascii_uppercase())
895}
896
897#[cfg(feature = "auth")]
898pub use store::{ssh_public_key_fingerprint_text, RegistryAuthStore};
899
900#[cfg(all(test, feature = "auth"))]
901mod tests {
902 use super::*;
903 use ed25519_dalek::SigningKey;
904
905 fn temporary_directory(prefix: &str) -> PathBuf {
906 let unique = format!(
907 "{}-{}-{}",
908 prefix,
909 std::process::id(),
910 std::time::SystemTime::now()
911 .duration_since(std::time::UNIX_EPOCH)
912 .expect("system time")
913 .as_nanos()
914 );
915 let path = std::env::temp_dir().join(unique);
916 fs::create_dir_all(&path).expect("temporary directory");
917 path
918 }
919
920 #[test]
921 fn registers_and_verifies_email_with_required_confirmation() {
922 let root = temporary_directory("pray-auth-required");
923 let store = RegistryAuthStore::open(&root).expect("open store");
924
925 let registration = store
926 .register_email("alice@example.com", EmailConfirmationPolicy::Required)
927 .expect("register");
928 assert!(!registration.verified);
929 let code = registration
930 .verification_code
931 .as_ref()
932 .expect("verification code");
933 assert_eq!(code.len(), 6);
934 assert!(!store
935 .user_verified("alice@example.com")
936 .expect("user state"));
937
938 let verification = store
939 .verify_email("alice@example.com", code)
940 .expect("verify");
941 assert!(verification.verified);
942 assert!(store
943 .user_verified("alice@example.com")
944 .expect("user state"));
945 }
946
947 #[test]
948 fn registers_email_without_confirmation_when_disabled() {
949 let root = temporary_directory("pray-auth-disabled");
950 let store = RegistryAuthStore::open(&root).expect("open store");
951
952 let registration = store
953 .register_email("bob@example.com", EmailConfirmationPolicy::Disabled)
954 .expect("register");
955 assert!(registration.verified);
956 assert!(registration.verification_code.is_none());
957 assert!(store.user_verified("bob@example.com").expect("user state"));
958 }
959
960 #[test]
961 fn issues_session_for_optional_email_without_confirmation() {
962 let root = temporary_directory("pray-auth-session");
963 let store = RegistryAuthStore::open(&root).expect("open store");
964
965 store
966 .register_email("carol@example.com", EmailConfirmationPolicy::Optional)
967 .expect("register");
968 let session = store
969 .issue_session("carol@example.com", AuthSessionKind::Email)
970 .expect("session");
971 assert_eq!(session.email, "carol@example.com");
972 assert!(session.token.starts_with("sha256:"));
973 assert_eq!(session.kind, AuthSessionKind::Email);
974 assert_eq!(
975 store
976 .resolve_session(&session.token)
977 .expect("resolve session")
978 .map(|session| session.email),
979 Some("carol@example.com".to_string())
980 );
981 }
982
983 #[test]
984 fn enrolls_and_logs_in_with_passkey_and_ssh_key() {
985 let root = temporary_directory("pray-auth-keys");
986 let store = RegistryAuthStore::open(&root).expect("open store");
987
988 let signing_key = signing_key_from_seed(17);
989 let public_key = ssh_public_key_text(&signing_key);
990
991 store
992 .register_email("dave@example.com", EmailConfirmationPolicy::Optional)
993 .expect("register");
994 let passkey = store
995 .enroll_passkey(
996 "dave@example.com",
997 "credential-1",
998 &public_key,
999 Some("laptop passkey"),
1000 )
1001 .expect("passkey enrollment");
1002 assert!(passkey.enrolled);
1003 let passkey_login = store
1004 .login_with_passkey("credential-1")
1005 .expect("passkey login");
1006 assert_eq!(passkey_login.email, "dave@example.com");
1007
1008 let ssh_key = store
1009 .enroll_ssh_key("dave@example.com", &public_key, Some("workstation"))
1010 .expect("ssh enrollment");
1011 assert!(ssh_key.enrolled);
1012 let ssh_login = store.login_with_ssh_key(&public_key).expect("ssh login");
1013 assert_eq!(ssh_login.email, "dave@example.com");
1014 }
1015
1016 fn ssh_public_key_text(signing_key: &SigningKey) -> String {
1017 let mut blob = Vec::new();
1018 write_ssh_string(&mut blob, b"ssh-ed25519");
1019 write_ssh_string(&mut blob, &signing_key.verifying_key().to_bytes());
1020 format!("ssh-ed25519 {}", STANDARD.encode(blob))
1021 }
1022
1023 fn write_ssh_string(buffer: &mut Vec<u8>, bytes: &[u8]) {
1024 buffer.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
1025 buffer.extend_from_slice(bytes);
1026 }
1027
1028 fn signing_key_from_seed(seed: u8) -> SigningKey {
1029 SigningKey::from_bytes(&[seed; 32])
1030 }
1031}