Skip to main content

pray_core/
auth.rs

1#[cfg(not(feature = "auth"))]
2use crate::hashing::sha256_prefixed;
3#[cfg(not(feature = "auth"))]
4use crate::{PrayError, PrayResult};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct AuthRegistrationRequest {
9    pub email: String,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct AuthVerificationRequest {
14    pub email: String,
15    pub code: String,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct AuthSessionRequest {
20    pub email: String,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct AuthPasskeyEnrollmentRequest {
25    pub email: String,
26    pub credential_id: String,
27    pub public_key: String,
28    #[serde(default)]
29    pub label: Option<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct AuthPasskeyChallengeRequest {
34    pub credential_id: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct AuthPasskeyChallengeResponse {
39    pub credential_id: String,
40    pub challenge_id: String,
41    pub challenge: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct AuthPasskeyLoginRequest {
46    pub credential_id: String,
47    pub challenge_id: String,
48    pub signature: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct AuthSshKeyEnrollmentRequest {
53    pub email: String,
54    pub public_key: String,
55    #[serde(default)]
56    pub label: Option<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct AuthSshKeyChallengeRequest {
61    pub public_key: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct AuthSshKeyChallengeResponse {
66    pub fingerprint: String,
67    pub challenge_id: String,
68    pub challenge: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct AuthSshKeyLoginRequest {
73    pub public_key: String,
74    pub challenge_id: String,
75    pub signature: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct AuthRegistrationResponse {
80    pub email: String,
81    pub verified: bool,
82    #[serde(default)]
83    pub verification_code: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct AuthVerificationResponse {
88    pub email: String,
89    pub verified: bool,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum AuthSessionKind {
95    Email,
96    Passkey,
97    SshKey,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct AuthSessionResponse {
102    pub email: String,
103    pub token: String,
104    pub kind: AuthSessionKind,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct AuthPasskeyEnrollmentResponse {
109    pub email: String,
110    pub credential_id: String,
111    pub enrolled: bool,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct AuthPasskeyLoginResponse {
116    pub email: String,
117    pub token: String,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct AuthChallengeResponse {
122    pub challenge_id: String,
123    pub challenge: String,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct AuthSshKeyEnrollmentResponse {
128    pub email: String,
129    pub fingerprint: String,
130    pub enrolled: bool,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct AuthSshKeyLoginResponse {
135    pub email: String,
136    pub token: String,
137}
138
139#[cfg(feature = "auth")]
140pub use crate::auth_store::{ssh_public_key_fingerprint_text, RegistryAuthStore};
141
142#[cfg(not(feature = "auth"))]
143pub fn ssh_public_key_fingerprint_text(public_key: &str) -> PrayResult<String> {
144    let mut fields = public_key.split_whitespace();
145    let algorithm = fields.next().ok_or_else(|| PrayError::Parse {
146        kind: "public key",
147        message: "public key must include an algorithm".to_string(),
148    })?;
149    let encoded_key = fields.next().ok_or_else(|| PrayError::Parse {
150        kind: "public key",
151        message: "public key must include key bytes".to_string(),
152    })?;
153    if algorithm != "ssh-ed25519" {
154        return Err(PrayError::Unsupported(format!(
155            "unsupported public key algorithm: {algorithm}"
156        )));
157    }
158
159    Ok(sha256_prefixed(format!("{algorithm} {encoded_key}").as_bytes()).to_ascii_uppercase())
160}
161
162#[cfg(all(test, feature = "auth"))]
163mod tests {
164    use super::*;
165    use crate::trust::EmailConfirmationPolicy;
166    use base64::{engine::general_purpose::STANDARD, Engine as _};
167    use ed25519_dalek::SigningKey;
168    use std::fs;
169    use std::path::PathBuf;
170
171    fn temporary_directory(prefix: &str) -> PathBuf {
172        let unique = format!(
173            "{}-{}-{}",
174            prefix,
175            std::process::id(),
176            std::time::SystemTime::now()
177                .duration_since(std::time::UNIX_EPOCH)
178                .expect("system time")
179                .as_nanos()
180        );
181        let path = std::env::temp_dir().join(unique);
182        fs::create_dir_all(&path).expect("temporary directory");
183        path
184    }
185
186    #[test]
187    fn registers_and_verifies_email_with_required_confirmation() {
188        let root = temporary_directory("pray-auth-required");
189        let store = RegistryAuthStore::open(&root).expect("open store");
190
191        let registration = store
192            .register_email("alice@example.com", EmailConfirmationPolicy::Required)
193            .expect("register");
194        assert!(!registration.verified);
195        let code = registration
196            .verification_code
197            .as_ref()
198            .expect("verification code");
199        assert_eq!(code.len(), 6);
200        assert!(!store
201            .user_verified("alice@example.com")
202            .expect("user state"));
203
204        let verification = store
205            .verify_email("alice@example.com", code)
206            .expect("verify");
207        assert!(verification.verified);
208        assert!(store
209            .user_verified("alice@example.com")
210            .expect("user state"));
211    }
212
213    #[test]
214    fn registers_email_without_confirmation_when_disabled() {
215        let root = temporary_directory("pray-auth-disabled");
216        let store = RegistryAuthStore::open(&root).expect("open store");
217
218        let registration = store
219            .register_email("bob@example.com", EmailConfirmationPolicy::Disabled)
220            .expect("register");
221        assert!(registration.verified);
222        assert!(registration.verification_code.is_none());
223        assert!(store.user_verified("bob@example.com").expect("user state"));
224    }
225
226    #[test]
227    fn issues_session_for_optional_email_without_confirmation() {
228        let root = temporary_directory("pray-auth-session");
229        let store = RegistryAuthStore::open(&root).expect("open store");
230
231        store
232            .register_email("carol@example.com", EmailConfirmationPolicy::Optional)
233            .expect("register");
234        let session = store
235            .issue_session("carol@example.com", AuthSessionKind::Email)
236            .expect("session");
237        assert_eq!(session.email, "carol@example.com");
238        assert!(session.token.starts_with("sha256:"));
239        assert_eq!(session.kind, AuthSessionKind::Email);
240        assert_eq!(
241            store
242                .resolve_session(&session.token)
243                .expect("resolve session")
244                .map(|session| session.email),
245            Some("carol@example.com".to_string())
246        );
247    }
248
249    #[test]
250    fn enrolls_and_logs_in_with_passkey_and_ssh_key() {
251        let root = temporary_directory("pray-auth-keys");
252        let store = RegistryAuthStore::open(&root).expect("open store");
253
254        let signing_key = signing_key_from_seed(17);
255        let public_key = ssh_public_key_text(&signing_key);
256
257        store
258            .register_email("dave@example.com", EmailConfirmationPolicy::Optional)
259            .expect("register");
260        let passkey = store
261            .enroll_passkey(
262                "dave@example.com",
263                "credential-1",
264                &public_key,
265                Some("laptop passkey"),
266            )
267            .expect("passkey enrollment");
268        assert!(passkey.enrolled);
269        let passkey_login = store
270            .login_with_passkey("credential-1")
271            .expect("passkey login");
272        assert_eq!(passkey_login.email, "dave@example.com");
273
274        let ssh_key = store
275            .enroll_ssh_key("dave@example.com", &public_key, Some("workstation"))
276            .expect("ssh enrollment");
277        assert!(ssh_key.enrolled);
278        let ssh_login = store.login_with_ssh_key(&public_key).expect("ssh login");
279        assert_eq!(ssh_login.email, "dave@example.com");
280    }
281
282    fn ssh_public_key_text(signing_key: &SigningKey) -> String {
283        let mut blob = Vec::new();
284        write_ssh_string(&mut blob, b"ssh-ed25519");
285        write_ssh_string(&mut blob, &signing_key.verifying_key().to_bytes());
286        format!("ssh-ed25519 {}", STANDARD.encode(blob))
287    }
288
289    fn write_ssh_string(buffer: &mut Vec<u8>, bytes: &[u8]) {
290        buffer.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
291        buffer.extend_from_slice(bytes);
292    }
293
294    fn signing_key_from_seed(seed: u8) -> SigningKey {
295        SigningKey::from_bytes(&[seed; 32])
296    }
297}