Skip to main content

pray_core/
auth.rs

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}