1
/*
2
 * This file is part of mailpot
3
 *
4
 * Copyright 2020 - Manos Pitsidianakis
5
 *
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU Affero General Public License as
8
 * published by the Free Software Foundation, either version 3 of the
9
 * License, or (at your option) any later version.
10
 *
11
 * This program is distributed in the hope that it will be useful,
12
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
 * GNU Affero General Public License for more details.
15
 *
16
 * You should have received a copy of the GNU Affero General Public License
17
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18
 */
19

            
20
use std::{borrow::Cow, process::Stdio};
21

            
22
use tempfile::NamedTempFile;
23
use tokio::{fs::File, io::AsyncWriteExt, process::Command};
24

            
25
use super::*;
26

            
27
const TOKEN_KEY: &str = "ssh_challenge";
28
const EXPIRY_IN_SECS: i64 = 6 * 60;
29

            
30
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, Eq, PartialEq, PartialOrd)]
31
pub enum Role {
32
    User,
33
    Admin,
34
}
35

            
36
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
37
pub struct User {
38
    /// SSH signature.
39
    pub ssh_signature: String,
40
    /// User role.
41
    pub role: Role,
42
    /// Database primary key.
43
    pub pk: i64,
44
    /// Accounts's display name, optional.
45
    pub name: Option<String>,
46
    /// Account's e-mail address.
47
    pub address: String,
48
    /// GPG public key.
49
    pub public_key: Option<String>,
50
    /// SSH public key.
51
    pub password: String,
52
    /// Whether this account is enabled.
53
    pub enabled: bool,
54
}
55

            
56
impl AuthUser<i64, Role> for User {
57
    fn get_id(&self) -> i64 {
58
        self.pk
59
    }
60

            
61
    fn get_password_hash(&self) -> SecretVec<u8> {
62
        SecretVec::new(self.ssh_signature.clone().into())
63
    }
64

            
65
    fn get_role(&self) -> Option<Role> {
66
        Some(self.role)
67
    }
68
}
69

            
70
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
71
pub struct AuthFormPayload {
72
    pub address: String,
73
    pub password: String,
74
}
75

            
76
pub async fn ssh_signin(
77
    _: LoginPath,
78
    mut session: WritableSession,
79
    Query(next): Query<Next>,
80
    auth: AuthContext,
81
    State(state): State<Arc<AppState>>,
82
) -> impl IntoResponse {
83
    if auth.current_user.is_some() {
84
        if let Err(err) = session.add_message(Message {
85
            message: "You are already logged in.".into(),
86
            level: Level::Info,
87
        }) {
88
            return err.into_response();
89
        }
90
        return next
91
            .or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri()))
92
            .into_response();
93
    }
94
    if next.next.is_some() {
95
        if let Err(err) = session.add_message(Message {
96
            message: "You need to be logged in to access this page.".into(),
97
            level: Level::Info,
98
        }) {
99
            return err.into_response();
100
        };
101
    }
102

            
103
    let now: i64 = chrono::offset::Utc::now().timestamp();
104

            
105
    let prev_token = if let Some(tok) = session.get::<(String, i64)>(TOKEN_KEY) {
106
        let timestamp: i64 = tok.1;
107
        if !(timestamp < now && now - timestamp < EXPIRY_IN_SECS) {
108
            session.remove(TOKEN_KEY);
109
            None
110
        } else {
111
            Some(tok)
112
        }
113
    } else {
114
        None
115
    };
116

            
117
    let (token, timestamp): (String, i64) = prev_token.map_or_else(
118
        || {
119
            use rand::{distributions::Alphanumeric, thread_rng, Rng};
120

            
121
            let mut rng = thread_rng();
122
            let chars: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
123
            println!("Random chars: {}", chars);
124
            session.insert(TOKEN_KEY, (&chars, now)).unwrap();
125
            (chars, now)
126
        },
127
        |tok| tok,
128
    );
129
    let timeout_left = ((timestamp + EXPIRY_IN_SECS) - now) as f64 / 60.0;
130

            
131
    let root_url_prefix = &state.root_url_prefix;
132
    let crumbs = vec![
133
        Crumb {
134
            label: "Home".into(),
135
            url: "/".into(),
136
        },
137
        Crumb {
138
            label: "Sign in".into(),
139
            url: LoginPath.to_crumb(),
140
        },
141
    ];
142

            
143
    let context = minijinja::context! {
144
        namespace => &state.public_url,
145
        title => state.site_title.as_ref(),
146
        page_title => "Log in",
147
        description => "",
148
        root_url_prefix => &root_url_prefix,
149
        ssh_challenge => token,
150
        timeout_left => timeout_left,
151
        current_user => auth.current_user,
152
        messages => session.drain_messages(),
153
        crumbs => crumbs,
154
    };
155
    Html(
156
        TEMPLATES
157
            .get_template("auth.html")
158
            .unwrap()
159
            .render(context)
160
            .unwrap_or_else(|err| err.to_string()),
161
    )
162
    .into_response()
163
}
164

            
165
pub async fn ssh_signin_post(
166
    _: LoginPath,
167
    mut session: WritableSession,
168
    Query(next): Query<Next>,
169
    mut auth: AuthContext,
170
    Form(payload): Form<AuthFormPayload>,
171
    state: Arc<AppState>,
172
) -> Result<Redirect, ResponseError> {
173
    if auth.current_user.as_ref().is_some() {
174
        session.add_message(Message {
175
            message: "You are already logged in.".into(),
176
            level: Level::Info,
177
        })?;
178
        return Ok(next.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri())));
179
    }
180

            
181
    let now: i64 = chrono::offset::Utc::now().timestamp();
182

            
183
    let (_prev_token, _) = if let Some(tok @ (_, timestamp)) =
184
        session.get::<(String, i64)>(TOKEN_KEY)
185
    {
186
        if !(timestamp < now && now - timestamp < EXPIRY_IN_SECS) {
187
            session.add_message(Message {
188
                message: "The token has expired. Please retry.".into(),
189
                level: Level::Error,
190
            })?;
191
            return Ok(Redirect::to(&format!(
192
                "{}{}?next={}",
193
                state.root_url_prefix,
194
                LoginPath.to_uri(),
195
                next.next.as_ref().map_or(Cow::Borrowed(""), |next| format!(
196
                    "?next={}",
197
                    percent_encoding::utf8_percent_encode(
198
                        next.as_str(),
199
                        percent_encoding::CONTROLS
200
                    )
201
                )
202
                .into())
203
            )));
204
        } else {
205
            tok
206
        }
207
    } else {
208
        session.add_message(Message {
209
            message: "The token has expired. Please retry.".into(),
210
            level: Level::Error,
211
        })?;
212
        return Ok(Redirect::to(&format!(
213
            "{}{}{}",
214
            state.root_url_prefix,
215
            LoginPath.to_uri(),
216
            next.next.as_ref().map_or(Cow::Borrowed(""), |next| format!(
217
                "?next={}",
218
                percent_encoding::utf8_percent_encode(next.as_str(), percent_encoding::CONTROLS)
219
            )
220
            .into())
221
        )));
222
    };
223

            
224
    let db = Connection::open_db(state.conf.clone())?;
225
    let mut acc = match db
226
        .account_by_address(&payload.address)
227
        .with_status(StatusCode::BAD_REQUEST)?
228
    {
229
        Some(v) => v,
230
        None => {
231
            session.add_message(Message {
232
                message: "Invalid account details, please retry.".into(),
233
                level: Level::Error,
234
            })?;
235
            return Ok(Redirect::to(&format!(
236
                "{}{}{}",
237
                state.root_url_prefix,
238
                LoginPath.to_uri(),
239
                next.next.as_ref().map_or(Cow::Borrowed(""), |next| format!(
240
                    "?next={}",
241
                    percent_encoding::utf8_percent_encode(
242
                        next.as_str(),
243
                        percent_encoding::CONTROLS
244
                    )
245
                )
246
                .into())
247
            )));
248
        }
249
    };
250
    #[cfg(not(debug_assertions))]
251
    let sig = SshSignature {
252
        email: payload.address.clone(),
253
        ssh_public_key: acc.password.clone(),
254
        ssh_signature: payload.password.clone(),
255
        namespace: std::env::var("SSH_NAMESPACE")
256
            .unwrap_or_else(|_| "lists.mailpot.rs".to_string())
257
            .into(),
258
        token: _prev_token,
259
    };
260
    #[cfg(not(debug_assertions))]
261
    if let Err(err) = ssh_keygen(sig).await {
262
        session.add_message(Message {
263
            message: format!("Could not verify signature: {err}").into(),
264
            level: Level::Error,
265
        })?;
266
        return Ok(Redirect::to(&format!(
267
            "{}{}{}",
268
            state.root_url_prefix,
269
            LoginPath.to_uri(),
270
            next.next.as_ref().map_or(Cow::Borrowed(""), |next| format!(
271
                "?next={}",
272
                percent_encoding::utf8_percent_encode(next.as_str(), percent_encoding::CONTROLS)
273
            )
274
            .into())
275
        )));
276
    }
277

            
278
    let user = User {
279
        pk: acc.pk(),
280
        ssh_signature: payload.password,
281
        role: if db
282
            .conf()
283
            .administrators
284
            .iter()
285
            .any(|a| a.eq_ignore_ascii_case(&payload.address))
286
        {
287
            Role::Admin
288
        } else {
289
            Role::User
290
        },
291
        public_key: std::mem::take(&mut acc.public_key),
292
        password: std::mem::take(&mut acc.password),
293
        name: std::mem::take(&mut acc.name),
294
        address: payload.address,
295
        enabled: acc.enabled,
296
    };
297
    state.insert_user(acc.pk(), user.clone()).await;
298
    drop(session);
299
    auth.login(&user)
300
        .await
301
        .map_err(|err| ResponseError::new(err.to_string(), StatusCode::BAD_REQUEST))?;
302
    Ok(next.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri())))
303
}
304

            
305
2
#[derive(Debug, Clone, Default)]
306
pub struct SshSignature {
307
1
    pub email: String,
308
1
    pub ssh_public_key: String,
309
1
    pub ssh_signature: String,
310
1
    pub namespace: Cow<'static, str>,
311
1
    pub token: String,
312
}
313

            
314
/// Run ssh signature validation with `ssh-keygen` binary.
315
///
316
/// ```no_run
317
/// use mailpot_web::{ssh_keygen, SshSignature};
318
///
319
/// async fn key_gen(
320
///     ssh_public_key: String,
321
///     ssh_signature: String,
322
/// ) -> std::result::Result<(), Box<dyn std::error::Error>> {
323
///     let mut sig = SshSignature {
324
///         email: "user@example.com".to_string(),
325
///         ssh_public_key,
326
///         ssh_signature,
327
///         namespace: "doc-test@example.com".into(),
328
///         token: "d074a61990".to_string(),
329
///     };
330
///
331
///     ssh_keygen(sig.clone()).await?;
332
///     Ok(())
333
/// }
334
/// ```
335
19
pub async fn ssh_keygen(sig: SshSignature) -> Result<(), Box<dyn std::error::Error>> {
336
    let SshSignature {
337
2
        email,
338
2
        ssh_public_key,
339
2
        ssh_signature,
340
2
        namespace,
341
2
        token,
342
    } = sig;
343
2
    let dir = tempfile::tempdir()?;
344

            
345
2
    let mut allowed_signers_fp = NamedTempFile::new_in(dir.path())?;
346
2
    let mut signature_fp = NamedTempFile::new_in(dir.path())?;
347
    {
348
2
        let (tempfile, path) = allowed_signers_fp.into_parts();
349
2
        let mut file = File::from(tempfile);
350

            
351
4
        file.write_all(format!("{email} {ssh_public_key}").as_bytes())
352
4
            .await?;
353
4
        file.flush().await?;
354
2
        allowed_signers_fp = NamedTempFile::from_parts(file.into_std().await, path);
355
2
    }
356
    {
357
2
        let (tempfile, path) = signature_fp.into_parts();
358
2
        let mut file = File::from(tempfile);
359

            
360
4
        file.write_all(ssh_signature.trim().replace("\r\n", "\n").as_bytes())
361
4
            .await?;
362
4
        file.flush().await?;
363
2
        signature_fp = NamedTempFile::from_parts(file.into_std().await, path);
364
2
    }
365

            
366
2
    let mut cmd = Command::new("ssh-keygen");
367

            
368
2
    cmd.stdout(Stdio::piped());
369
2
    cmd.stderr(Stdio::piped());
370
2
    cmd.stdin(Stdio::piped());
371

            
372
    // Once you have your allowed signers file, verification works like this:
373
    //
374
    // ```shell
375
    // ssh-keygen -Y verify -f allowed_signers -I alice@example.com -n file -s file_to_verify.sig < file_to_verify
376
    // ```
377
    //
378
    // Here are the arguments you may need to change:
379
    //
380
    // - `allowed_signers` is the path to the allowed signers file.
381
    // - `alice@example.com` is the email address of the person who allegedly signed
382
    //   the file. This email address is looked up in the allowed signers file to
383
    //   get possible public keys.
384
    // - `file` is the "namespace", which must match the namespace used for signing
385
    //   as described above.
386
    // - `file_to_verify.sig` is the path to the signature file.
387
    // - `file_to_verify` is the path to the file to be verified. Note that this
388
    //   file is read from standard in. In the above command, the < shell operator
389
    //   is used to redirect standard in from this file.
390
    //
391
    // If the signature is valid, the command exits with status `0` and prints a
392
    // message like this:
393
    //
394
    // > Good "file" signature for alice@example.com with ED25519 key
395
    // > SHA256:ZGa8RztddW4kE2XKPPsP9ZYC7JnMObs6yZzyxg8xZSk
396
    //
397
    // Otherwise, the command exits with a non-zero status and prints an error
398
    // message.
399

            
400
10
    let mut child = cmd
401
        .arg("-Y")
402
        .arg("verify")
403
        .arg("-f")
404
2
        .arg(allowed_signers_fp.path())
405
        .arg("-I")
406
2
        .arg(&email)
407
        .arg("-n")
408
2
        .arg(namespace.as_ref())
409
        .arg("-s")
410
2
        .arg(signature_fp.path())
411
        .spawn()
412
2
        .expect("failed to spawn command");
413

            
414
2
    let mut stdin = child
415
        .stdin
416
        .take()
417
2
        .expect("child did not have a handle to stdin");
418

            
419
6
    stdin
420
2
        .write_all(token.as_bytes())
421
4
        .await
422
        .expect("could not write to stdin");
423

            
424
2
    drop(stdin);
425

            
426
11
    let op = child.wait_with_output().await?;
427

            
428
2
    if !op.status.success() {
429
3
        return Err(format!(
430
            "ssh-keygen exited with {}:\nstdout: {}\n\nstderr: {}",
431
1
            op.status.code().unwrap_or(-1),
432
1
            String::from_utf8_lossy(&op.stdout),
433
1
            String::from_utf8_lossy(&op.stderr)
434
        )
435
        .into());
436
    }
437

            
438
1
    Ok(())
439
4
}
440

            
441
pub async fn logout_handler(
442
    _: LogoutPath,
443
    mut auth: AuthContext,
444
    State(state): State<Arc<AppState>>,
445
) -> Redirect {
446
    auth.logout().await;
447
    Redirect::to(&format!("{}/", state.root_url_prefix))
448
}
449

            
450
pub mod auth_request {
451
    use std::{marker::PhantomData, ops::RangeBounds};
452

            
453
    use axum::body::HttpBody;
454
    use dyn_clone::DynClone;
455
    use tower_http::auth::AuthorizeRequest;
456

            
457
    use super::*;
458

            
459
    trait RoleBounds<Role>: DynClone + Send + Sync {
460
        fn contains(&self, role: Option<Role>) -> bool;
461
    }
462

            
463
    impl<T, Role> RoleBounds<Role> for T
464
    where
465
        Role: PartialOrd + PartialEq,
466
        T: RangeBounds<Role> + Clone + Send + Sync,
467
    {
468
        fn contains(&self, role: Option<Role>) -> bool {
469
            role.as_ref()
470
                .map_or_else(|| role.is_none(), |role| RangeBounds::contains(self, role))
471
        }
472
    }
473

            
474
    /// Type that performs login authorization.
475
    ///
476
    /// See [`RequireAuthorizationLayer::login`] for more details.
477
    pub struct Login<UserId, User, ResBody, Role = ()> {
478
        login_url: Option<Arc<Cow<'static, str>>>,
479
        redirect_field_name: Option<Arc<Cow<'static, str>>>,
480
        role_bounds: Box<dyn RoleBounds<Role>>,
481
        _user_id_type: PhantomData<UserId>,
482
        _user_type: PhantomData<User>,
483
        _body_type: PhantomData<fn() -> ResBody>,
484
    }
485

            
486
    impl<UserId, User, ResBody, Role> Clone for Login<UserId, User, ResBody, Role> {
487
        fn clone(&self) -> Self {
488
            Self {
489
                login_url: self.login_url.clone(),
490
                redirect_field_name: self.redirect_field_name.clone(),
491
                role_bounds: dyn_clone::clone_box(&*self.role_bounds),
492
                _user_id_type: PhantomData,
493
                _user_type: PhantomData,
494
                _body_type: PhantomData,
495
            }
496
        }
497
    }
498

            
499
    impl<UserId, User, ReqBody, ResBody, Role> AuthorizeRequest<ReqBody>
500
        for Login<UserId, User, ResBody, Role>
501
    where
502
        Role: PartialOrd + PartialEq + Clone + Send + Sync + 'static,
503
        User: AuthUser<UserId, Role>,
504
        ResBody: HttpBody + Default,
505
    {
506
        type ResponseBody = ResBody;
507

            
508
        fn authorize(
509
            &mut self,
510
            request: &mut Request<ReqBody>,
511
        ) -> Result<(), Response<Self::ResponseBody>> {
512
            let user = request
513
                .extensions()
514
                .get::<Option<User>>()
515
                .expect("Auth extension missing. Is the auth layer installed?");
516

            
517
            match user {
518
                Some(user) if self.role_bounds.contains(user.get_role()) => {
519
                    let user = user.clone();
520
                    request.extensions_mut().insert(user);
521

            
522
                    Ok(())
523
                }
524

            
525
                _ => {
526
                    let unauthorized_response = if let Some(ref login_url) = self.login_url {
527
                        let url: Cow<'static, str> = self.redirect_field_name.as_ref().map_or_else(
528
                            || login_url.as_ref().clone(),
529
                            |next| {
530
                                format!(
531
                                    "{login_url}?{next}={}",
532
                                    percent_encoding::utf8_percent_encode(
533
                                        request.uri().path(),
534
                                        percent_encoding::CONTROLS
535
                                    )
536
                                )
537
                                .into()
538
                            },
539
                        );
540

            
541
                        Response::builder()
542
                            .status(http::StatusCode::TEMPORARY_REDIRECT)
543
                            .header(http::header::LOCATION, url.as_ref())
544
                            .body(Default::default())
545
                            .unwrap()
546
                    } else {
547
                        Response::builder()
548
                            .status(http::StatusCode::UNAUTHORIZED)
549
                            .body(Default::default())
550
                            .unwrap()
551
                    };
552

            
553
                    Err(unauthorized_response)
554
                }
555
            }
556
        }
557
    }
558

            
559
    /// A wrapper around [`tower_http::auth::RequireAuthorizationLayer`] which
560
    /// provides login authorization.
561
    pub struct RequireAuthorizationLayer<UserId, User, Role = ()>(UserId, User, Role);
562

            
563
    impl<UserId, User, Role> RequireAuthorizationLayer<UserId, User, Role>
564
    where
565
        Role: PartialOrd + PartialEq + Clone + Send + Sync + 'static,
566
        User: AuthUser<UserId, Role>,
567
    {
568
        /// Authorizes requests by requiring a logged in user, otherwise it
569
        /// rejects with [`http::StatusCode::UNAUTHORIZED`].
570
        pub fn login<ResBody>(
571
        ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
572
        where
573
            ResBody: HttpBody + Default,
574
        {
575
            tower_http::auth::RequireAuthorizationLayer::custom(Login::<_, _, _, _> {
576
                login_url: None,
577
                redirect_field_name: None,
578
                role_bounds: Box::new(..),
579
                _user_id_type: PhantomData,
580
                _user_type: PhantomData,
581
                _body_type: PhantomData,
582
            })
583
        }
584

            
585
        /// Authorizes requests by requiring a logged in user to have a specific
586
        /// range of roles, otherwise it rejects with
587
        /// [`http::StatusCode::UNAUTHORIZED`].
588
        pub fn login_with_role<ResBody>(
589
            role_bounds: impl RangeBounds<Role> + Clone + Send + Sync + 'static,
590
        ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
591
        where
592
            ResBody: HttpBody + Default,
593
        {
594
            tower_http::auth::RequireAuthorizationLayer::custom(Login::<_, _, _, _> {
595
                login_url: None,
596
                redirect_field_name: None,
597
                role_bounds: Box::new(role_bounds),
598
                _user_id_type: PhantomData,
599
                _user_type: PhantomData,
600
                _body_type: PhantomData,
601
            })
602
        }
603

            
604
        /// Authorizes requests by requiring a logged in user, otherwise it
605
        /// redirects to the provided login URL.
606
        ///
607
        /// If `redirect_field_name` is set to a value, the login page will
608
        /// receive the path it was redirected from in the URI query
609
        /// part. For example, attempting to visit a protected path
610
        /// `/protected` would redirect you to `/login?next=/protected` allowing
611
        /// you to know how to return the visitor to their requested
612
        /// page.
613
        pub fn login_or_redirect<ResBody>(
614
            login_url: Arc<Cow<'static, str>>,
615
            redirect_field_name: Option<Arc<Cow<'static, str>>>,
616
        ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
617
        where
618
            ResBody: HttpBody + Default,
619
        {
620
            tower_http::auth::RequireAuthorizationLayer::custom(Login::<_, _, _, _> {
621
                login_url: Some(login_url),
622
                redirect_field_name,
623
                role_bounds: Box::new(..),
624
                _user_id_type: PhantomData,
625
                _user_type: PhantomData,
626
                _body_type: PhantomData,
627
            })
628
        }
629

            
630
        /// Authorizes requests by requiring a logged in user to have a specific
631
        /// range of roles, otherwise it redirects to the
632
        /// provided login URL.
633
        ///
634
        /// If `redirect_field_name` is set to a value, the login page will
635
        /// receive the path it was redirected from in the URI query
636
        /// part. For example, attempting to visit a protected path
637
        /// `/protected` would redirect you to `/login?next=/protected` allowing
638
        /// you to know how to return the visitor to their requested
639
        /// page.
640
        pub fn login_with_role_or_redirect<ResBody>(
641
            role_bounds: impl RangeBounds<Role> + Clone + Send + Sync + 'static,
642
            login_url: Arc<Cow<'static, str>>,
643
            redirect_field_name: Option<Arc<Cow<'static, str>>>,
644
        ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
645
        where
646
            ResBody: HttpBody + Default,
647
        {
648
            tower_http::auth::RequireAuthorizationLayer::custom(Login::<_, _, _, _> {
649
                login_url: Some(login_url),
650
                redirect_field_name,
651
                role_bounds: Box::new(role_bounds),
652
                _user_id_type: PhantomData,
653
                _user_type: PhantomData,
654
                _body_type: PhantomData,
655
            })
656
        }
657
    }
658
}
659

            
660
#[cfg(test)]
661
mod tests {
662
    use super::*;
663

            
664
18
    #[tokio::test]
665
2
    async fn test_ssh_keygen() {
666
        const PKEY: &str = concat!("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCzXp8nLJL8GPNw7S+Dqt0m3Dw/",
667
            "xFOAdwKXcekTFI9cLDEUII2rNPf0uUZTpv57OgU+",
668
            "QOEEIvWMjz+5KSWBX8qdP8OtV0QNvynlZkEKZN0cUqGKaNXo5a+PUDyiJ2rHroPe1aMo6mUBL9kLR6J2U1CYD/dLfL8ywXsAGmOL0bsK0GRPVBJAjpUNRjpGU/",
669
            "2FFIlU6s6GawdbDXEHDox/UoOVAKIlhKabaTrFBA0ACFLRX2/GCBmHqqt5d4ZZjefYzReLs/beOjafYImoyhHC428wZDcUjvLrpSJbIOE/",
670
            "gSPCWlRbcsxg4JGcKOtALUurE+ok+avy9M7eFjGhLGSlTKLdshIVQr/3W667M7bYfOT6xP/",
671
            "lyjxeWIUYyj7rjlqKJ9tzygek7QNxCtuqH5xsZAZqzQCN8wfrPAlwDykvWityKOw+Bt2DWjimITqyKgsBsOaA+",
672
            "eVCllFvooJxoYvAjODASjAUoOdgVzyBDpFnOhLFYiIIyL3F6NROS9i7z086paX7mrzcQzvLr4ckF9qT7DrI88ikISCR9bFR4vPq3aH",
673
            "zJdjDDpWxACa5b11NG8KdCJPe/L0kDw82Q00U13CpW9FI9sZjvk+",
674
            "lyw8bTFvVsIl6A0ueboFvrNvznAqHrtfWu75fXRh5sKj2TGk8rhm3vyNgrBSr5zAfFVM8LgqBxbAAYw==");
675

            
676
        const SIG: &str = concat!(
677
            "-----BEGIN SSH SIGNATURE-----\n",
678
            "U1NIU0lHAAAAAQAAAhcAAAAHc3NoLXJzYQAAAAMBAAEAAAIBALNenycskvwY83DtL4Oq3S\n",
679
            "bcPD/EU4B3Apdx6RMUj1wsMRQgjas09/S5RlOm/ns6BT5A4QQi9YyPP7kpJYFfyp0/w61X\n",
680
            "RA2/KeVmQQpk3RxSoYpo1ejlr49QPKInaseug97VoyjqZQEv2QtHonZTUJgP90t8vzLBew\n",
681
            "AaY4vRuwrQZE9UEkCOlQ1GOkZT/YUUiVTqzoZrB1sNcQcOjH9Sg5UAoiWEpptpOsUEDQAI\n",
682
            "UtFfb8YIGYeqq3l3hlmN59jNF4uz9t46Np9giajKEcLjbzBkNxSO8uulIlsg4T+BI8JaVF\n",
683
            "tyzGDgkZwo60AtS6sT6iT5q/L0zt4WMaEsZKVMot2yEhVCv/dbrrsztth85PrE/+XKPF5Y\n",
684
            "hRjKPuuOWoon23PKB6TtA3EK26ofnGxkBmrNAI3zB+s8CXAPKS9aK3Io7D4G3YNaOKYhOr\n",
685
            "IqCwGw5oD55UKWUW+ignGhi8CM4MBKMBSg52BXPIEOkWc6EsViIgjIvcXo1E5L2LvPTzql\n",
686
            "pfuavNxDO8uvhyQX2pPsOsjzyKQhIJH1sVHi8+rdofMl2MMOlbEAJrlvXU0bwp0Ik978vS\n",
687
            "QPDzZDTRTXcKlb0Uj2xmO+T6XLDxtMW9WwiXoDS55ugW+s2/OcCoeu19a7vl9dGHmwqPZM\n",
688
            "aTyuGbe/I2CsFKvnMB8VUzwuCoHFsABjAAAAFGRvYy10ZXN0QGV4YW1wbGUuY29tAAAAAA\n",
689
            "AAAAZzaGE1MTIAAAIUAAAADHJzYS1zaGEyLTUxMgAAAgBxaMqIfeapKTrhQzggDssD+76s\n",
690
            "jZxv3XxzgsuAjlIdtw+/nyxU6skTnrGoam2shpmQvx0HuqSQ7HyS2USBK7T4LZNoE53zR/\n",
691
            "ZmHLGoyQAoexiHSEW9Lk53kyRNPhpXQedTvm8REHPGM3zw6WO6mAXVVxvebvawf81LTbBb\n",
692
            "p9ubNRcHgktVeywMO/sD6zWSyShq1gjVv1PdRBOjUgqkwjImL8dFKi1QUeoffCxyk3JhTO\n",
693
            "siTy79HZSz/kOvkvL1vQuqaP2R8lE9P1uaD19dGOMTPRod3u+QmpYX47ri5KM3Fmkfxdwq\n",
694
            "p8JVmfAA9nme7bmNS1hWgmF2Nbh9qjh1zOZvCimIpuNtz5eEl9K+1DxG6w5tX86wSGvBMO\n",
695
            "znx0k1gGfkiAULqgrkdul7mqMPRvPN9J6QlNJ7SLFChRhzlJIJc6tOvCs7qkVD43Zcb+I5\n",
696
            "Z+K4NiFf5jf8kVX/pjjeW/ucbrctJIkGsZ58OkHKi1EDRcq7NtCF6SKlcv8g3fMLd9wW6K\n",
697
            "aaed0TBDC+s+f6naNIGvWqfWCwDuK5xGyDTTmJGcrsMwWuT9K6uLk8cGdv7t5mOFuWi5jl\n",
698
            "E+IKZKVABMuWqSj96ErMIiBjtsAZfNSezpsK49wQztoSPhdwLhD6fHrSAyPCqN2xRkcsIb\n",
699
            "6PxWKC/OELf3gyEBRPouxsF7xSZQ==\n",
700
            "-----END SSH SIGNATURE-----\n"
701
        );
702

            
703
2
        let mut sig = SshSignature {
704
1
            email: "user@example.com".to_string(),
705
1
            ssh_public_key: PKEY.to_string(),
706
1
            ssh_signature: SIG.to_string(),
707
1
            namespace: "doc-test@example.com".into(),
708
1
            token: "d074a61990".to_string(),
709
        };
710

            
711
8
        ssh_keygen(sig.clone()).await.unwrap();
712

            
713
1
        sig.ssh_signature = sig.ssh_signature.replace('J', "0");
714

            
715
9
        let err = ssh_keygen(sig).await.unwrap_err();
716

            
717
2
        assert!(
718
1
            err.to_string().starts_with("ssh-keygen exited with"),
719
            "{}",
720
            err
721
1
        );
722
3
    }
723
}