Skip to main content

io_imap/rfc7677/
auth_scram_sha_256.rs

1//! IMAP SASL SCRAM-SHA-256 coroutine; supports both the non-IR and
2//! SASL-IR (RFC 4959) flows.
3//!
4//! The mechanism itself lives in io-sasl: this coroutine holds the IMAP
5//! half of the exchange, the `AUTHENTICATE SCRAM-SHA-256` command, the
6//! continuation requests, the tagged response and the post-auth
7//! follow-ups, and asks [`SaslScramSha256`] what to put in each
8//! response. The salted password, the client proof and the
9//! verification of the server signature are the mechanism's, and so is
10//! the refusal of an exchange that ends before that verification ran.
11//!
12//! The client nonce travels with the credentials, an I/O-free coroutine
13//! having no source of randomness; [`ImapClientStd::connect`] draws one
14//! for credentials that carry none.
15//!
16//! [`ImapClientStd::connect`]: crate::client::ImapClientStd::connect
17//!
18//! SCRAM: <https://www.rfc-editor.org/rfc/rfc5802>
19//! SCRAM-SHA-256: <https://www.rfc-editor.org/rfc/rfc7677>
20//! SASL-IR: <https://www.rfc-editor.org/rfc/rfc4959>
21//!
22//! # Example
23//!
24//! ```rust,no_run
25//! use std::{
26//!     io::{Read, Write},
27//!     net::TcpStream,
28//! };
29//!
30//! use io_imap::{
31//!     codec::fragmentizer::Fragmentizer,
32//!     coroutine::{ImapCoroutine, ImapCoroutineState, ImapYield},
33//!     rfc7677::auth_scram_sha_256::{ImapAuthScramSha256, ImapAuthScramSha256Options},
34//! };
35//! use io_sasl::{rfc5801::SaslGs2ChannelBinding, rfc5802::SaslScramCreds};
36//! use secrecy::SecretString;
37//!
38//! // Ready stream needed (TCP-connected, TLS-negotiated)
39//! let mut stream = TcpStream::connect("localhost:143").unwrap();
40//!
41//! let mut fragmentizer = Fragmentizer::new(50 * 1024 * 1024);
42//! let mut buf = [0u8; 4096];
43//!
44//! // NOTE: a real client draws its nonce from a cryptographic source.
45//! let creds = SaslScramCreds {
46//!     username: "alice".into(),
47//!     password: SecretString::from("secret"),
48//!     nonce: b"fyko+d2lbbFgONRv9qkxdawL".to_vec(),
49//!     channel_binding: SaslGs2ChannelBinding::Unsupported,
50//! };
51//!
52//! let opts = ImapAuthScramSha256Options::default();
53//! let mut coroutine = ImapAuthScramSha256::new(creds, opts);
54//! let mut arg = None;
55//!
56//! let capability = loop {
57//!     match coroutine.resume(&mut fragmentizer, arg.take()) {
58//!         ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => {
59//!             stream.write_all(&bytes).unwrap();
60//!         }
61//!         ImapCoroutineState::Yielded(ImapYield::WantsRead) => {
62//!             let n = stream.read(&mut buf).unwrap();
63//!             arg = Some(&buf[..n]);
64//!         }
65//!         ImapCoroutineState::Complete(Ok(capability)) => break capability,
66//!         ImapCoroutineState::Complete(Err(err)) => panic!("{err}"),
67//!     }
68//! };
69//!
70//! println!("{capability:?}");
71//! ```
72
73use core::{fmt, mem};
74
75use alloc::{
76    string::{String, ToString},
77    vec::Vec,
78};
79
80use imap_codec::{
81    AuthenticateDataCodec, CommandCodec,
82    fragmentizer::Fragmentizer,
83    imap_types::{
84        auth::{AuthMechanism, AuthenticateData},
85        command::{Command, CommandBody},
86        core::{IString, NString, TagGenerator},
87        response::{
88            Capability, Code, CommandContinuationRequest, Data, StatusBody, StatusKind, Tagged,
89        },
90        secret::Secret,
91    },
92};
93use io_sasl::{
94    coroutine::*,
95    rfc5802::{SaslScramCreds, SaslScramError},
96    rfc7677::scram_sha_256::SaslScramSha256,
97};
98use log::{debug, trace};
99use thiserror::Error;
100
101use crate::{coroutine::*, imap_try, rfc2971::id::*, rfc3501::capability::*, send::*};
102
103/// Failure causes during the SASL SCRAM-SHA-256 flow.
104#[derive(Clone, Debug, Error)]
105pub enum ImapAuthScramSha256Error {
106    /// The server rejected authentication with a tagged NO.
107    #[error("IMAP AUTHENTICATE SCRAM-SHA-256 failed: NO {0}")]
108    No(String),
109    /// The server rejected the AUTHENTICATE command with a tagged BAD.
110    #[error("IMAP AUTHENTICATE SCRAM-SHA-256 failed: BAD {0}")]
111    Bad(String),
112    /// The server closed the connection with an untagged BYE.
113    #[error("IMAP AUTHENTICATE SCRAM-SHA-256 failed: BYE {0}")]
114    Bye(String),
115    /// The server never returned the final tagged response.
116    #[error("IMAP AUTHENTICATE SCRAM-SHA-256 failed: server did not return a tagged response")]
117    MissingTagged,
118    /// The server never sent the expected continuation request.
119    #[error(
120        "IMAP AUTHENTICATE SCRAM-SHA-256 failed: server did not send the expected continuation request"
121    )]
122    ExpectedContinuationRequest,
123    /// The server returned OK before the mechanism could complete.
124    #[error(
125        "IMAP AUTHENTICATE SCRAM-SHA-256 failed: server returned OK before the mechanism could complete"
126    )]
127    UnexpectedOk,
128    /// The mechanism refused the exchange.
129    ///
130    /// Every RFC 5802 failure lands here: a malformed server message, a
131    /// server nonce that does not extend the client one, an error the
132    /// server reported in place of its proof, a signature that does not
133    /// match, and an exchange ending before that signature was checked
134    /// at all.
135    #[error("IMAP AUTHENTICATE SCRAM-SHA-256 failed: {0}")]
136    Mechanism(#[from] SaslScramError),
137    /// The underlying send coroutine failed.
138    #[error("IMAP AUTHENTICATE SCRAM-SHA-256 failed: {0}")]
139    Send(#[from] ImapSendError),
140    /// The follow-up CAPABILITY command failed.
141    #[error(transparent)]
142    Capability(#[from] ImapCapabilityGetError),
143    /// The follow-up ID command failed.
144    #[error(transparent)]
145    ServerId(#[from] ImapServerIdError),
146}
147
148/// Options for [`ImapAuthScramSha256::new`].
149#[derive(Clone, Debug, Default, Eq, PartialEq)]
150pub struct ImapAuthScramSha256Options {
151    /// `true` selects SASL-IR (RFC 4959, inline client-first-message);
152    /// `false` selects the non-IR upload-after-challenge flow.
153    pub initial_request: bool,
154    /// Fetch CAPABILITY after authentication when the tagged response
155    /// carries no capability data. Defaults to `false`.
156    pub ensure_capabilities: bool,
157    /// Chain an RFC 2971 ID round-trip right after authentication, as
158    /// required by some providers.
159    ///
160    /// Defaults to `None` (no ID); an empty list sends ID NIL.
161    pub auto_id: Option<Vec<(IString<'static>, NString<'static>)>>,
162}
163
164/// I/O-free SASL SCRAM-SHA-256 coroutine.
165pub struct ImapAuthScramSha256 {
166    state: State,
167    mechanism: SaslScramSha256,
168    observed: Vec<Capability<'static>>,
169    opts: ImapAuthScramSha256Options,
170}
171
172impl ImapAuthScramSha256 {
173    /// Builds a SASL SCRAM-SHA-256 coroutine from `creds`.
174    ///
175    /// The credentials carry the client nonce, which must be printable
176    /// ASCII without commas and which RFC 5802 wants drawn from at
177    /// least 18 bytes of cryptographic randomness. It is an input
178    /// rather than something drawn here, an I/O-free coroutine having
179    /// no source of randomness, and it makes the exchange
180    /// deterministically testable.
181    ///
182    /// They also carry the channel binding, which decides whether the
183    /// exchange announces `SCRAM-SHA-256` or `SCRAM-SHA-256-PLUS`. This
184    /// crate never asks a TLS session what it exported, so a caller
185    /// wanting a bound exchange extracts the material itself.
186    ///
187    /// Depending on `opts.initial_request`, the client-first-message
188    /// goes inline with the AUTHENTICATE command (SASL-IR) or is
189    /// uploaded after the server challenge.
190    pub fn new(creds: SaslScramCreds, opts: ImapAuthScramSha256Options) -> Self {
191        Self {
192            state: State::Start,
193            mechanism: SaslScramSha256::new(creds),
194            observed: Vec::new(),
195            opts,
196        }
197    }
198
199    // helper that tells if the coroutine needs to fetch capability or not (in
200    // case found in data or untagged responses)
201    fn wants_capability(
202        &mut self,
203        code: Option<Code<'static>>,
204        data: Vec<Data<'static>>,
205        untagged: Vec<StatusBody<'static>>,
206    ) -> Option<State> {
207        let mut new_capability = None;
208
209        if let Some(Code::Capability(capability)) = code {
210            new_capability.replace(capability);
211        }
212
213        for data in data {
214            if let Data::Capability(capability) = data {
215                new_capability.replace(capability);
216            }
217        }
218
219        for StatusBody { code, .. } in untagged {
220            if let Some(Code::Capability(capability)) = code {
221                new_capability.replace(capability);
222            }
223        }
224
225        if let Some(capability) = new_capability {
226            self.observed = capability.into_iter().collect();
227        }
228
229        (self.opts.ensure_capabilities && self.observed.is_empty())
230            .then(|| State::Capability(ImapCapabilityGet::new()))
231    }
232
233    // helper that tells if the coroutine needs to exchange ID with server
234    fn wants_id(&mut self) -> Option<State> {
235        let params = self.opts.auto_id.take()?;
236        let wire = (!params.is_empty()).then_some(params);
237        Some(State::Id(ImapServerId::new(ImapServerIdOptions {
238            parameters: wire,
239        })))
240    }
241
242    // helper that tells if the coroutine needs to send continuation auth data
243    fn wants_continue(payload: Vec<u8>) -> State {
244        let auth = AuthenticateData::r#continue(payload);
245        let codec = AuthenticateDataCodec::new();
246        State::Continue(ImapSend::new(codec, auth))
247    }
248
249    // helper that resumes SASL coroutine
250    fn resume_sasl(
251        &mut self,
252        arg: SaslArg<'_>,
253    ) -> Result<Option<Vec<u8>>, ImapAuthScramSha256Error> {
254        match self.mechanism.resume(arg) {
255            SaslCoroutineState::Yielded(SaslYield::WantsWrite(payload)) => Ok(Some(payload)),
256            SaslCoroutineState::Yielded(SaslYield::WantsRead) => Ok(None),
257            SaslCoroutineState::Complete(result) => result.map(|()| None).map_err(Into::into),
258        }
259    }
260}
261
262impl ImapCoroutine for ImapAuthScramSha256 {
263    type Yield = ImapYield;
264    type Return = Result<Vec<Capability<'static>>, ImapAuthScramSha256Error>;
265
266    fn resume(
267        &mut self,
268        fragmentizer: &mut Fragmentizer,
269        arg: Option<&[u8]>,
270    ) -> ImapCoroutineState<Self::Yield, Self::Return> {
271        loop {
272            match &mut self.state {
273                State::Start => {
274                    let payload = match self.resume_sasl(SaslArg::None) {
275                        Ok(payload) => payload,
276                        Err(err) => return ImapCoroutineState::Complete(Err(err)),
277                    };
278
279                    // NOTE: the initial response travels inline only when the
280                    // server was found to support RFC 4959, which is a decision
281                    // taken before the exchange; otherwise it waits for the
282                    // empty challenge.
283                    let (initial_response, pending) = match payload {
284                        Some(payload) if self.opts.initial_request => {
285                            (Some(Secret::new(payload.into())), None)
286                        }
287                        payload => (None, payload),
288                    };
289
290                    let tag = TagGenerator::new().generate();
291                    let body = CommandBody::Authenticate {
292                        mechanism: AuthMechanism::ScramSha256,
293                        initial_response,
294                    };
295                    let cmd = Command { tag, body };
296                    trace!("send IMAP command {cmd:?}");
297
298                    self.state = State::Send {
299                        send: ImapSend::new(CommandCodec::new(), cmd),
300                        pending,
301                    };
302                    debug!("{}", self.state);
303                }
304                State::Send { send, pending } => {
305                    let out = imap_try!(send, fragmentizer, arg);
306
307                    if let Some(bye) = out.bye {
308                        let err = ImapAuthScramSha256Error::Bye(bye.text.to_string());
309                        return ImapCoroutineState::Complete(Err(err));
310                    }
311
312                    if let Some(cr) = out.continuation_request {
313                        // NOTE: with the client-first-message still held back
314                        // this is the empty challenge inviting it; with it
315                        // already inlined the challenge is the
316                        // server-first-message, which only the mechanism reads.
317                        let payload = match pending.take() {
318                            Some(payload) => payload,
319                            None => {
320                                match self.resume_sasl(SaslArg::Input(&extract_challenge(cr))) {
321                                    Ok(payload) => payload.unwrap_or_default(),
322                                    Err(err) => return ImapCoroutineState::Complete(Err(err)),
323                                }
324                            }
325                        };
326
327                        self.state = Self::wants_continue(payload);
328                        debug!("{}", self.state);
329                        continue;
330                    }
331
332                    let inlined = pending.is_none();
333
334                    let Some(Tagged { body, .. }) = out.tagged else {
335                        let err = ImapAuthScramSha256Error::ExpectedContinuationRequest;
336                        return ImapCoroutineState::Complete(Err(err));
337                    };
338
339                    let code = match body.kind {
340                        StatusKind::Ok if inlined => body.code,
341                        StatusKind::Ok => {
342                            let err = ImapAuthScramSha256Error::UnexpectedOk;
343                            return ImapCoroutineState::Complete(Err(err));
344                        }
345                        StatusKind::No => {
346                            let err = ImapAuthScramSha256Error::No(body.text.to_string());
347                            return ImapCoroutineState::Complete(Err(err));
348                        }
349                        StatusKind::Bad => {
350                            let err = ImapAuthScramSha256Error::Bad(body.text.to_string());
351                            return ImapCoroutineState::Complete(Err(err));
352                        }
353                    };
354
355                    // NOTE: a server ending the exchange this early proved
356                    // nothing, and the mechanism says so rather than this
357                    // crate guessing: SCRAM refuses every end that comes
358                    // before it verified the server signature.
359                    if let Err(err) = self.resume_sasl(SaslArg::Done) {
360                        return ImapCoroutineState::Complete(Err(err));
361                    }
362
363                    if let Some(next) = self.wants_capability(code, out.data, out.untagged) {
364                        self.state = next;
365                        debug!("{}", self.state);
366                        continue;
367                    }
368
369                    if let Some(next) = self.wants_id() {
370                        self.state = next;
371                        debug!("{}", self.state);
372                        continue;
373                    }
374
375                    let capability = mem::take(&mut self.observed);
376                    return ImapCoroutineState::Complete(Ok(capability));
377                }
378                State::Continue(send) => {
379                    let out = imap_try!(send, fragmentizer, arg);
380
381                    if let Some(bye) = out.bye {
382                        let err = ImapAuthScramSha256Error::Bye(bye.text.to_string());
383                        return ImapCoroutineState::Complete(Err(err));
384                    }
385
386                    if let Some(cr) = out.continuation_request {
387                        let payload = match self.resume_sasl(SaslArg::Input(&extract_challenge(cr)))
388                        {
389                            Ok(payload) => payload.unwrap_or_default(),
390                            Err(err) => return ImapCoroutineState::Complete(Err(err)),
391                        };
392
393                        self.state = Self::wants_continue(payload);
394                        debug!("{}", self.state);
395                        continue;
396                    }
397
398                    let Some(Tagged { body, .. }) = out.tagged else {
399                        let err = ImapAuthScramSha256Error::MissingTagged;
400                        return ImapCoroutineState::Complete(Err(err));
401                    };
402
403                    let code = match body.kind {
404                        StatusKind::Ok => body.code,
405                        StatusKind::No => {
406                            let err = ImapAuthScramSha256Error::No(body.text.to_string());
407                            return ImapCoroutineState::Complete(Err(err));
408                        }
409                        StatusKind::Bad => {
410                            let err = ImapAuthScramSha256Error::Bad(body.text.to_string());
411                            return ImapCoroutineState::Complete(Err(err));
412                        }
413                    };
414
415                    // NOTE: the tagged OK ends the exchange, and the mechanism
416                    // is told so rather than dropped. A server piggybacking
417                    // its server-final-message on that OK instead of sending
418                    // it as a continuation is refused here, where this crate
419                    // used to accept it and report a success nobody verified.
420                    if let Err(err) = self.resume_sasl(SaslArg::Done) {
421                        return ImapCoroutineState::Complete(Err(err));
422                    }
423
424                    if let Some(next) = self.wants_capability(code, out.data, out.untagged) {
425                        self.state = next;
426                        debug!("{}", self.state);
427                        continue;
428                    }
429
430                    if let Some(next) = self.wants_id() {
431                        self.state = next;
432                        debug!("{}", self.state);
433                        continue;
434                    }
435
436                    let capability = mem::take(&mut self.observed);
437                    return ImapCoroutineState::Complete(Ok(capability));
438                }
439                State::Capability(capability) => {
440                    self.observed = imap_try!(capability, fragmentizer, arg);
441
442                    if let Some(next) = self.wants_id() {
443                        self.state = next;
444                        debug!("{}", self.state);
445                        continue;
446                    }
447
448                    let capability = mem::take(&mut self.observed);
449                    return ImapCoroutineState::Complete(Ok(capability));
450                }
451                State::Id(id) => {
452                    imap_try!(id, fragmentizer, arg);
453                    let capability = mem::take(&mut self.observed);
454                    return ImapCoroutineState::Complete(Ok(capability));
455                }
456            }
457        }
458    }
459}
460
461enum State {
462    Start,
463    Send {
464        send: ImapSend<CommandCodec>,
465        pending: Option<Vec<u8>>,
466    },
467    Continue(ImapSend<AuthenticateDataCodec>),
468    Capability(ImapCapabilityGet),
469    Id(ImapServerId),
470}
471
472impl fmt::Display for State {
473    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
474        match self {
475            Self::Start => f.write_str("start mechanism"),
476            Self::Send { pending, .. } if pending.is_some() => f.write_str("send auth"),
477            Self::Send { .. } => f.write_str("send auth with ir"),
478            Self::Continue(_) => f.write_str("send response"),
479            Self::Capability(_) => f.write_str("fetch capabilities"),
480            Self::Id(_) => f.write_str("send id"),
481        }
482    }
483}
484
485fn extract_challenge(cr: CommandContinuationRequest<'static>) -> Vec<u8> {
486    match cr {
487        CommandContinuationRequest::Basic(basic) => basic.text().to_string().into_bytes(),
488        CommandContinuationRequest::Base64(data) => data.as_ref().to_vec(),
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use core::str;
495
496    use alloc::{borrow::ToOwned, format};
497
498    use base64::{Engine, engine::general_purpose::STANDARD};
499    use hmac::{Hmac, KeyInit, Mac};
500    use io_sasl::rfc5801::SaslGs2ChannelBinding;
501    use secrecy::SecretString;
502    use sha2::Sha256;
503
504    use crate::rfc7677::auth_scram_sha_256::*;
505
506    type HmacSha256 = Hmac<Sha256>;
507
508    const NONCE: &[u8] = b"fyko+d2lbbFgONRv9qkxdawL";
509
510    fn creds() -> SaslScramCreds {
511        SaslScramCreds {
512            username: "alice".to_string(),
513            password: SecretString::from("secret"),
514            nonce: NONCE.to_vec(),
515            channel_binding: SaslGs2ChannelBinding::Unsupported,
516        }
517    }
518
519    #[test]
520    fn ir_success_returns_ok() {
521        let opts = ImapAuthScramSha256Options {
522            initial_request: true,
523            ..Default::default()
524        };
525
526        let mut auth = ImapAuthScramSha256::new(creds(), opts);
527        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
528
529        let bytes = expect_wants_write(&mut auth, &mut frag, None);
530        let line = str::from_utf8(&bytes).expect("utf8 command");
531        let tag = first_word(line).to_owned();
532        let client_first = decode_last_base64_token(line);
533        let client_nonce = extract_client_nonce(&client_first);
534
535        expect_wants_read(&mut auth, &mut frag);
536
537        let server_first = format!("r={client_nonce}ServerExtra,s={SALT_B64},i={ITERATIONS}");
538        let challenge = format!("+ {}\r\n", STANDARD.encode(&server_first));
539        let client_final_bytes =
540            expect_wants_write(&mut auth, &mut frag, Some(challenge.as_bytes()));
541        let client_final_line = str::from_utf8(&client_final_bytes).expect("utf8");
542        let client_final = decode_last_base64_token(client_final_line.trim_end());
543
544        expect_wants_read(&mut auth, &mut frag);
545
546        let server_final = build_server_final(&client_first, &server_first, &client_final);
547        let challenge2 = format!("+ {}\r\n", STANDARD.encode(&server_final));
548        let ack = expect_wants_write(&mut auth, &mut frag, Some(challenge2.as_bytes()));
549        assert_eq!(b"\r\n", &*ack);
550
551        expect_wants_read(&mut auth, &mut frag);
552
553        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
554        expect_complete_ok(&mut auth, &mut frag, reply.as_bytes());
555    }
556
557    #[test]
558    fn ir_server_error_returns_mechanism_error() {
559        let opts = ImapAuthScramSha256Options {
560            initial_request: true,
561            ..Default::default()
562        };
563
564        let mut auth = ImapAuthScramSha256::new(creds(), opts);
565        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
566
567        let bytes = expect_wants_write(&mut auth, &mut frag, None);
568        let client_first = decode_last_base64_token(str::from_utf8(&bytes).expect("utf8"));
569        let client_nonce = extract_client_nonce(&client_first);
570
571        expect_wants_read(&mut auth, &mut frag);
572
573        let server_first = format!("r={client_nonce}ServerExtra,s={SALT_B64},i={ITERATIONS}");
574        let challenge = format!("+ {}\r\n", STANDARD.encode(&server_first));
575        let _client_final = expect_wants_write(&mut auth, &mut frag, Some(challenge.as_bytes()));
576
577        expect_wants_read(&mut auth, &mut frag);
578
579        let server_final = "e=invalid-proof";
580        let challenge2 = format!("+ {}\r\n", STANDARD.encode(server_final));
581        let err = expect_complete_err(&mut auth, &mut frag, challenge2.as_bytes());
582        let ImapAuthScramSha256Error::Mechanism(SaslScramError::ServerError(text)) = err else {
583            panic!("expected ImapAuthScramSha256Error::Mechanism, got {err:?}");
584        };
585        assert_eq!(text, "invalid-proof");
586    }
587
588    #[test]
589    fn ir_tagged_bad_returns_bad_error() {
590        let opts = ImapAuthScramSha256Options {
591            initial_request: true,
592            ..Default::default()
593        };
594
595        let mut auth = ImapAuthScramSha256::new(creds(), opts);
596        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
597
598        let bytes = expect_wants_write(&mut auth, &mut frag, None);
599        let tag = first_word(str::from_utf8(&bytes).expect("utf8"));
600
601        expect_wants_read(&mut auth, &mut frag);
602
603        let reply = format!("{tag} BAD AUTHENTICATE not enabled\r\n");
604        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
605        let ImapAuthScramSha256Error::Bad(text) = err else {
606            panic!("expected ImapAuthScramSha256Error::Bad, got {err:?}");
607        };
608        assert_eq!(text, "AUTHENTICATE not enabled");
609    }
610
611    #[test]
612    fn ir_tagged_ok_before_the_server_proved_itself_returns_mechanism_error() {
613        let opts = ImapAuthScramSha256Options {
614            initial_request: true,
615            ..Default::default()
616        };
617
618        let mut auth = ImapAuthScramSha256::new(creds(), opts);
619        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
620
621        let bytes = expect_wants_write(&mut auth, &mut frag, None);
622        let line = str::from_utf8(&bytes).expect("utf8 command");
623        let tag = first_word(line).to_owned();
624        let client_first = decode_last_base64_token(line);
625        let client_nonce = extract_client_nonce(&client_first);
626
627        expect_wants_read(&mut auth, &mut frag);
628
629        let server_first = format!("r={client_nonce}ServerExtra,s={SALT_B64},i={ITERATIONS}");
630        let challenge = format!("+ {}\r\n", STANDARD.encode(&server_first));
631        expect_wants_write(&mut auth, &mut frag, Some(challenge.as_bytes()));
632
633        expect_wants_read(&mut auth, &mut frag);
634
635        // NOTE: a tagged OK arriving in place of the
636        // server-final-message ends the exchange with the server
637        // signature unchecked, which the mechanism refuses. This crate
638        // used to report it as a success.
639        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
640        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
641        let ImapAuthScramSha256Error::Mechanism(SaslScramError::ServerSignatureNotVerified) = err
642        else {
643            panic!("expected ImapAuthScramSha256Error::Mechanism, got {err:?}");
644        };
645    }
646
647    #[test]
648    fn non_ir_success_returns_ok() {
649        let opts = ImapAuthScramSha256Options::default();
650        let mut auth = ImapAuthScramSha256::new(creds(), opts);
651        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
652
653        let bytes = expect_wants_write(&mut auth, &mut frag, None);
654        let line = str::from_utf8(&bytes).expect("utf8 command");
655        let tag = first_word(line).to_owned();
656        assert!(line.trim_end().ends_with("AUTHENTICATE SCRAM-SHA-256"));
657
658        expect_wants_read(&mut auth, &mut frag);
659
660        let client_first_bytes = expect_wants_write(&mut auth, &mut frag, Some(b"+ \r\n"));
661        let client_first = decode_last_base64_token(
662            str::from_utf8(&client_first_bytes)
663                .expect("utf8")
664                .trim_end(),
665        );
666        let client_nonce = extract_client_nonce(&client_first);
667
668        expect_wants_read(&mut auth, &mut frag);
669
670        let server_first = format!("r={client_nonce}ServerExtra,s={SALT_B64},i={ITERATIONS}");
671        let challenge = format!("+ {}\r\n", STANDARD.encode(&server_first));
672        let client_final_bytes =
673            expect_wants_write(&mut auth, &mut frag, Some(challenge.as_bytes()));
674        let client_final = decode_last_base64_token(
675            str::from_utf8(&client_final_bytes)
676                .expect("utf8")
677                .trim_end(),
678        );
679
680        expect_wants_read(&mut auth, &mut frag);
681
682        let server_final = build_server_final(&client_first, &server_first, &client_final);
683        let challenge2 = format!("+ {}\r\n", STANDARD.encode(&server_final));
684        let ack = expect_wants_write(&mut auth, &mut frag, Some(challenge2.as_bytes()));
685        assert_eq!(b"\r\n", &*ack);
686
687        expect_wants_read(&mut auth, &mut frag);
688
689        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
690        expect_complete_ok(&mut auth, &mut frag, reply.as_bytes());
691    }
692
693    #[test]
694    fn non_ir_server_error_returns_mechanism_error() {
695        let opts = ImapAuthScramSha256Options::default();
696        let mut auth = ImapAuthScramSha256::new(creds(), opts);
697        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
698
699        let bytes = expect_wants_write(&mut auth, &mut frag, None);
700        let _tag = first_word(str::from_utf8(&bytes).expect("utf8"));
701
702        expect_wants_read(&mut auth, &mut frag);
703
704        let client_first_bytes = expect_wants_write(&mut auth, &mut frag, Some(b"+ \r\n"));
705        let client_first = decode_last_base64_token(
706            str::from_utf8(&client_first_bytes)
707                .expect("utf8")
708                .trim_end(),
709        );
710        let client_nonce = extract_client_nonce(&client_first);
711
712        expect_wants_read(&mut auth, &mut frag);
713
714        let server_first = format!("r={client_nonce}ServerExtra,s={SALT_B64},i={ITERATIONS}");
715        let challenge = format!("+ {}\r\n", STANDARD.encode(&server_first));
716        let _client_final = expect_wants_write(&mut auth, &mut frag, Some(challenge.as_bytes()));
717
718        expect_wants_read(&mut auth, &mut frag);
719
720        let server_final = "e=invalid-proof";
721        let challenge2 = format!("+ {}\r\n", STANDARD.encode(server_final));
722        let err = expect_complete_err(&mut auth, &mut frag, challenge2.as_bytes());
723        let ImapAuthScramSha256Error::Mechanism(SaslScramError::ServerError(text)) = err else {
724            panic!("expected ImapAuthScramSha256Error::Mechanism, got {err:?}");
725        };
726        assert_eq!(text, "invalid-proof");
727    }
728
729    const SALT_B64: &str = "QSXCR+Q6sek8bf92";
730    const ITERATIONS: u32 = 4096;
731
732    fn expect_wants_write(
733        cor: &mut ImapAuthScramSha256,
734        frag: &mut Fragmentizer,
735        arg: Option<&[u8]>,
736    ) -> Vec<u8> {
737        match cor.resume(frag, arg) {
738            ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => bytes,
739            state => panic!("expected WantsWrite, got {state:?}"),
740        }
741    }
742
743    fn expect_wants_read(cor: &mut ImapAuthScramSha256, frag: &mut Fragmentizer) {
744        match cor.resume(frag, None) {
745            ImapCoroutineState::Yielded(ImapYield::WantsRead) => {}
746            state => panic!("expected WantsRead, got {state:?}"),
747        }
748    }
749
750    fn expect_complete_ok(cor: &mut ImapAuthScramSha256, frag: &mut Fragmentizer, reply: &[u8]) {
751        match cor.resume(frag, Some(reply)) {
752            ImapCoroutineState::Complete(Ok(_)) => {}
753            state => panic!("expected Complete(Ok), got {state:?}"),
754        }
755    }
756
757    fn expect_complete_err(
758        cor: &mut ImapAuthScramSha256,
759        frag: &mut Fragmentizer,
760        reply: &[u8],
761    ) -> ImapAuthScramSha256Error {
762        match cor.resume(frag, Some(reply)) {
763            ImapCoroutineState::Complete(Err(err)) => err,
764            state => panic!("expected Complete(Err), got {state:?}"),
765        }
766    }
767
768    fn first_word(line: &str) -> &str {
769        line.split_whitespace()
770            .next()
771            .expect("first whitespace-separated token")
772    }
773
774    fn decode_last_base64_token(line: &str) -> String {
775        let b64 = line
776            .trim_end()
777            .rsplit_terminator(char::is_whitespace)
778            .next()
779            .expect("token");
780        let bytes = STANDARD.decode(b64).expect("valid base64");
781        String::from_utf8(bytes).expect("valid utf8")
782    }
783
784    fn extract_client_nonce(client_first: &str) -> &str {
785        client_first
786            .rsplit_once("r=")
787            .expect("client-first has r=")
788            .1
789    }
790
791    /// The server-final-message a server holding the same password
792    /// would send, computed here rather than by the mechanism under
793    /// test, so that what verifies the signature is not what produced
794    /// it.
795    fn build_server_final(client_first: &str, server_first: &str, client_final: &str) -> String {
796        let client_first_bare = client_first.strip_prefix("n,,").expect("gs2 header");
797        let client_final_without_proof = client_final
798            .rsplit_once(",p=")
799            .expect("client-final has p=")
800            .0;
801        let auth_message =
802            format!("{client_first_bare},{server_first},{client_final_without_proof}");
803        let salt = STANDARD.decode(SALT_B64).expect("valid salt");
804
805        // NOTE: SaltedPassword = PBKDF2(SHA-256, password, salt, iterations).
806        let mut salted_password = [0u8; 32];
807        pbkdf2::pbkdf2_hmac::<Sha256>(b"secret", &salt, ITERATIONS, &mut salted_password);
808
809        // NOTE: ServerKey = HMAC(SaltedPassword, "Server Key").
810        let mut mac = HmacSha256::new_from_slice(&salted_password).unwrap();
811        mac.update(b"Server Key");
812        let server_key = mac.finalize().into_bytes();
813
814        // NOTE: ServerSignature = HMAC(ServerKey, AuthMessage).
815        let mut mac = HmacSha256::new_from_slice(&server_key).unwrap();
816        mac.update(auth_message.as_bytes());
817        let server_signature = mac.finalize().into_bytes();
818
819        format!("v={}", STANDARD.encode(server_signature))
820    }
821}