nusadb 0.1.0

Fast, stable native Rust driver and ORM for NusaDB (Nusa Wire Protocol 1.1).
Documentation
//! Client-side SCRAM-SHA-256 (RFC 5802 / RFC 7677), docs/wire-protocol.md ยง7.2.
//!
//! No channel binding in this build; the GS2 header is `n,,`. The flow:
//! `client-first` โ†’ `server-first` โ†’ `client-final` (+ proof) โ†’ `server-final` (`v=` verified in
//! constant time for mutual auth).

use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use hmac::{Hmac, Mac};
use rand::RngCore as _;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq as _;

use crate::error::{Error, Result};

type HmacSha256 = Hmac<Sha256>;

const GS2_HEADER: &str = "n,,";

fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
    // `new_from_slice` only errors for impossible key lengths; HMAC accepts any, so this cannot fail.
    let mut mac = <HmacSha256 as Mac>::new_from_slice(key).expect("HMAC accepts any key length");
    mac.update(msg);
    mac.finalize().into_bytes().into()
}

/// A SCRAM exchange in progress, holding the state needed to build `client-final` and verify the
/// server's signature.
pub struct Scram {
    user: String,
    password: String,
    client_first_bare: String,
    expected_server_sig: Vec<u8>,
}

impl Scram {
    /// Begin an exchange for `user`/`password`, returning `(state, client_first_message)`. The
    /// client-first is `n,,n=<user>,r=<nonce>` with a fresh 18-byte base64 nonce.
    pub fn start(user: &str, password: &str) -> Result<(Self, String)> {
        let mut raw = [0u8; 18];
        rand::rngs::OsRng.fill_bytes(&mut raw);
        let nonce = B64.encode(raw);
        let client_first_bare = format!("n={user},r={nonce}");
        let client_first = format!("{GS2_HEADER}{client_first_bare}");
        Ok((
            Self {
                user: user.to_owned(),
                password: password.to_owned(),
                client_first_bare,
                expected_server_sig: Vec::new(),
            },
            client_first,
        ))
    }

    /// Consume `server-first` (`r=<nonce>,s=<salt>,i=<iters>`) and produce `client-final`
    /// (`c=biws,r=<nonce>,p=<proof>`). Also stashes the expected server signature for [`Self::verify`].
    pub fn client_final(&mut self, server_first: &str) -> Result<String> {
        let (combined_nonce, salt, iterations) = parse_server_first(server_first)?;
        // The combined nonce must start with our client nonce (anti-replay); the server appends to it.
        let client_nonce = self
            .client_first_bare
            .strip_prefix(&format!("n={},r=", self.user))
            .ok_or_else(|| Error::Auth("malformed client-first state".to_owned()))?;
        if !combined_nonce.starts_with(client_nonce) {
            return Err(Error::Auth(
                "server nonce does not extend client nonce".to_owned(),
            ));
        }

        let mut salted = [0u8; 32];
        pbkdf2::pbkdf2_hmac::<Sha256>(self.password.as_bytes(), &salt, iterations, &mut salted);
        let client_key = hmac_sha256(&salted, b"Client Key");
        let stored_key: [u8; 32] = Sha256::digest(client_key).into();

        let channel_binding = B64.encode(GS2_HEADER.as_bytes()); // "biws"
        let without_proof = format!("c={channel_binding},r={combined_nonce}");
        let auth_message = format!(
            "{},{},{}",
            self.client_first_bare, server_first, without_proof
        );
        let client_sig = hmac_sha256(&stored_key, auth_message.as_bytes());

        let proof: Vec<u8> = client_key
            .iter()
            .zip(client_sig.iter())
            .map(|(k, s)| k ^ s)
            .collect();

        let server_key = hmac_sha256(&salted, b"Server Key");
        self.expected_server_sig = hmac_sha256(&server_key, auth_message.as_bytes()).to_vec();

        Ok(format!("{without_proof},p={}", B64.encode(proof)))
    }

    /// Verify `server-final` (`v=<base64 signature>`) against the expected value, constant-time.
    #[must_use]
    pub fn verify(&self, server_final: &str) -> bool {
        for field in server_final.split(',') {
            if let Some(value) = field.strip_prefix("v=") {
                if let Ok(sig) = B64.decode(value) {
                    return sig.ct_eq(&self.expected_server_sig).into();
                }
            }
        }
        false
    }
}

fn parse_server_first(message: &str) -> Result<(String, Vec<u8>, u32)> {
    let mut nonce = None;
    let mut salt = None;
    let mut iters = None;
    for field in message.split(',') {
        if let Some((key, value)) = field.split_once('=') {
            match key {
                "r" => nonce = Some(value.to_owned()),
                "s" => {
                    salt = Some(
                        B64.decode(value)
                            .map_err(|_| Error::Auth("bad salt in server-first".to_owned()))?,
                    );
                }
                "i" => {
                    iters = Some(
                        value
                            .parse::<u32>()
                            .map_err(|_| Error::Auth("bad iteration count".to_owned()))?,
                    );
                }
                _ => {}
            }
        }
    }
    match (nonce, salt, iters) {
        (Some(n), Some(s), Some(i)) if !n.is_empty() && !s.is_empty() && i > 0 => Ok((n, s, i)),
        _ => Err(Error::Auth("malformed server-first message".to_owned())),
    }
}