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::{
141 bearer_token_from_authorization, ssh_public_key_fingerprint_text, PublishTokenRecord,
142 RegistryAuthStore, PUBLISH_SCOPE,
143};
144
145#[cfg(not(feature = "auth"))]
146pub fn ssh_public_key_fingerprint_text(public_key: &str) -> PrayResult<String> {
147 let mut fields = public_key.split_whitespace();
148 let algorithm = fields.next().ok_or_else(|| PrayError::Parse {
149 kind: "public key",
150 message: "public key must include an algorithm".to_string(),
151 })?;
152 let encoded_key = fields.next().ok_or_else(|| PrayError::Parse {
153 kind: "public key",
154 message: "public key must include key bytes".to_string(),
155 })?;
156 if algorithm != "ssh-ed25519" {
157 return Err(PrayError::Unsupported(format!(
158 "unsupported public key algorithm: {algorithm}"
159 )));
160 }
161
162 Ok(sha256_prefixed(format!("{algorithm} {encoded_key}").as_bytes()).to_ascii_uppercase())
163}
164
165#[cfg(all(test, feature = "auth"))]
166mod tests {
167 use super::*;
168 use crate::trust::EmailConfirmationPolicy;
169 use base64::{engine::general_purpose::STANDARD, Engine as _};
170 use ed25519_dalek::SigningKey;
171 use std::fs;
172 use std::path::PathBuf;
173
174 fn temporary_directory(prefix: &str) -> PathBuf {
175 let unique = format!(
176 "{}-{}-{}",
177 prefix,
178 std::process::id(),
179 std::time::SystemTime::now()
180 .duration_since(std::time::UNIX_EPOCH)
181 .expect("system time")
182 .as_nanos()
183 );
184 let path = std::env::temp_dir().join(unique);
185 fs::create_dir_all(&path).expect("temporary directory");
186 path
187 }
188
189 #[test]
190 fn registers_and_verifies_email_with_required_confirmation() {
191 let root = temporary_directory("pray-auth-required");
192 let store = RegistryAuthStore::open(&root).expect("open store");
193
194 let registration = store
195 .register_email("alice@example.com", EmailConfirmationPolicy::Required)
196 .expect("register");
197 assert!(!registration.verified);
198 let code = registration
199 .verification_code
200 .as_ref()
201 .expect("verification code");
202 assert_eq!(code.len(), 6);
203 assert!(!store
204 .user_verified("alice@example.com")
205 .expect("user state"));
206
207 let verification = store
208 .verify_email("alice@example.com", code)
209 .expect("verify");
210 assert!(verification.verified);
211 assert!(store
212 .user_verified("alice@example.com")
213 .expect("user state"));
214 }
215
216 #[test]
217 fn registers_email_without_confirmation_when_disabled() {
218 let root = temporary_directory("pray-auth-disabled");
219 let store = RegistryAuthStore::open(&root).expect("open store");
220
221 let registration = store
222 .register_email("bob@example.com", EmailConfirmationPolicy::Disabled)
223 .expect("register");
224 assert!(registration.verified);
225 assert!(registration.verification_code.is_none());
226 assert!(store.user_verified("bob@example.com").expect("user state"));
227 }
228
229 #[test]
230 fn issues_session_for_optional_email_without_confirmation() {
231 let root = temporary_directory("pray-auth-session");
232 let store = RegistryAuthStore::open(&root).expect("open store");
233
234 store
235 .register_email("carol@example.com", EmailConfirmationPolicy::Optional)
236 .expect("register");
237 let session = store
238 .issue_session("carol@example.com", AuthSessionKind::Email)
239 .expect("session");
240 assert_eq!(session.email, "carol@example.com");
241 assert!(session.token.starts_with("sha256:"));
242 assert_eq!(session.kind, AuthSessionKind::Email);
243 assert_eq!(
244 store
245 .resolve_session(&session.token)
246 .expect("resolve session")
247 .map(|session| session.email),
248 Some("carol@example.com".to_string())
249 );
250 }
251
252 #[test]
253 fn enrolls_and_logs_in_with_passkey_and_ssh_key() {
254 let root = temporary_directory("pray-auth-keys");
255 let store = RegistryAuthStore::open(&root).expect("open store");
256
257 let signing_key = signing_key_from_seed(17);
258 let public_key = ssh_public_key_text(&signing_key);
259
260 store
261 .register_email("dave@example.com", EmailConfirmationPolicy::Optional)
262 .expect("register");
263 let passkey = store
264 .enroll_passkey(
265 "dave@example.com",
266 "credential-1",
267 &public_key,
268 Some("laptop passkey"),
269 )
270 .expect("passkey enrollment");
271 assert!(passkey.enrolled);
272 let passkey_login = store
273 .login_with_passkey("credential-1")
274 .expect("passkey login");
275 assert_eq!(passkey_login.email, "dave@example.com");
276
277 let ssh_key = store
278 .enroll_ssh_key("dave@example.com", &public_key, Some("workstation"))
279 .expect("ssh enrollment");
280 assert!(ssh_key.enrolled);
281 let ssh_login = store.login_with_ssh_key(&public_key).expect("ssh login");
282 assert_eq!(ssh_login.email, "dave@example.com");
283 }
284
285 fn ssh_public_key_text(signing_key: &SigningKey) -> String {
286 let mut blob = Vec::new();
287 write_ssh_string(&mut blob, b"ssh-ed25519");
288 write_ssh_string(&mut blob, &signing_key.verifying_key().to_bytes());
289 format!("ssh-ed25519 {}", STANDARD.encode(blob))
290 }
291
292 fn write_ssh_string(buffer: &mut Vec<u8>, bytes: &[u8]) {
293 buffer.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
294 buffer.extend_from_slice(bytes);
295 }
296
297 fn signing_key_from_seed(seed: u8) -> SigningKey {
298 SigningKey::from_bytes(&[seed; 32])
299 }
300}