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}