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::{
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}