[Translation] Password authentication in Rust

This article is translated from the blog chapter of the author of the book Zero To Production In Rust Password verification in Rust

Machine translation has a strong taste, please let me know if there is anything wrong. This article talks about the security problems encountered in the payment system, in-depth problems layer by layer, and gradually expand the business and security knowledge encountered, supplemented by code and unit testing.

I learned safety knowledge in it as follows

  1. Password authentication, Basic Auth
  2. Password storage, (the focus of this article), encrypted passwords and their attack methods and prevention methods
  3. Cryptographic network exchange, TLS
  4. Authentication flow, OAuth

Mainly use RustCrypto crate, where sha3, base64, argon2

The following text begins

This article is an example from Zero to Production in Rust, a book about backend development in Rust.
You can get a copy of the book at zero2prod.com.
After subscribing to the email, you can receive notifications of newly published articles in time.

1. Securing our APIs

In Chapter 9, we added a new endpoint to the API, POST /newsletters. It takes the newsletter question as input and sends an email to all subscribers.

But we have a problem where anyone can hit the API and broadcast whatever they want to our entire mailing list.

It's time to upgrade our API security capabilities.
While password authentication is the easiest method of authentication, there are a few pitfalls, so we'll start from scratch with basic authentication, from which we'll examine several classes of attacks against the API, and how to counter them.

For pedagogical purposes, this chapter, like the rest of the book, deals with learning from making mistakes. Be sure to read to the end of the article if you don't want to develop bad security habits!

Chapter 10, Part 0

  1. Protect our API
  2. certified
    1. shortcoming
      1. things they know
      2. something they have
      3. what are they
    2. multi-factor authentication
  3. password-based authentication
    1. basic authentication
      1. Extract credentials
    2. password authentication, the naive approach
    3. password storage
      1. No need to store the original password
      2. use cryptographic hash
      3. preimage attack
      4. Naive Dictionary Attack
      5. dictionary attack
      6. Aragon2
      7. Salt
      8. PHC string format
    4. Don't block async executors
      1. Tracking context is thread-local
    5. user enumeration
  4. is it safe?
    1. Transport Layer Security (TLS)
    2. reset Password
    3. interaction type
    4. machine to machine
      1. Client Credentials via OAuth2
    5. people via browser
      1. joint identity
    6. machine to machine, on behalf of a person
  5. what should we do next

2. Authentication

We need a way to check who called POST /newsletters. Only a few people (those responsible for the content) can send mail to the entire mailing list.

First find out the identity of the caller , and then authenticate them . How to do?

Ask the caller for their own unique information. There are multiple approaches to this, all falling into 3 categories:

  1. something they know (e.g. password, PIN, security question);
  2. something they own (e.g. smartphone, using an authenticator app);
  3. They are something (e.g. fingerprints, Apple's Face ID).

Each method has its weaknesses.

2.1. Disadvantages

2.1.1. What they know

The password must be long enough, short ones are vulnerable to brute force attacks.
Passwords must be unique, and publicly available information (such as dates of birth, names of family members, etc.) should not give attackers any chance to "guess" the password.
Passwords should not be reused, and if any of them are compromised, you risk granting access to other services that share the same password.

On average, a person has 100 or more online accounts and they cannot be required to remember hundreds of long unique passwords. Password managers help, but they're not yet mainstream, and the user experience is often suboptimal.

2.1.2. What they have

Smartphones and U2F keys can be lost, locking users out of their accounts. They can also be stolen or leaked, giving attackers the opportunity to impersonate a victim.

2.1.3. They are something

Biometrics, unlike passwords, cannot be changed, you cannot "rotate" your fingerprint or change the pattern of retinal blood vessels. It turns out that forging fingerprints is easier than most people think, and it's information that government agencies often have access to, which they can misuse or lose.

2.2. Multi-factor authentication

Since each method has its own flaws, what should we do? Well, we can combine them!

This is pretty much multi-factor authentication (MFA), which requires users to provide at least two different types of authentication factors to gain access.

3. Password-based authentication

Let's move from theory to practice: how do we achieve certification?

Password looks like the easiest of the three methods we mentioned. How should we pass username and password to our API?

3.1. Basic authentication

We can use the "Basic" authentication scheme, which is a standard defined by the Internet Engineering Task Force (IETF) in RFC 2617 and later updated by RFC 7617.

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Authentication

The API must look for the Authorization header in incoming requests, which has the following structure:

Authorization: Basic <encoded credentials>

Where is the base64 encoding of {username}:{password} 1

According to the specification, we need to divide the API into protection spaces or realms, and resources within the same realm are protected using the same authentication scheme and set of credentials. We only need to protect one endpoint POST /newsletters. Therefore, we will have a realm called publish.

The API must reject all requests with missing headers or with invalid credentials, the response must use a 401 Unauthorized status code and contain the special header WWW-Authenticate, containing the challenge. A challenge is a string explaining to the API caller what type of authentication scheme we would like to see in the associated realm. In our case, with basic auth, it should be:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="publish"

Let's make it happen!

3.1.1. Extract Credentials

Extracting the username and password from the incoming request will be our first milestone.
Let's start with an unpleasant case, a rejected incoming request with no Authorization header.

//! tests/api/newsletter.rs
// [...]

#[tokio::test]
async fn requests_missing_authorization_are_rejected() {
    // Arrange
    let app = spawn_app().await;

    let response = reqwest::Client::new()
        .post(&format!("{}/newsletters", &app.address))
        .json(&serde_json::json!({
            "title": "Newsletter title",
            "content": {
                "text": "Newsletter body as plain text",
                "html": "<p>Newsletter body as HTML</p>",
            }
        }))
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert_eq!(401, response.status().as_u16());
    assert_eq!(r#"Basic realm="publish""#, response.headers()["WWW-Authenticate"]);
}

It fails at the first assertion:

thread 'newsletter::requests_missing_authorization_are_rejected' panicked at 
'assertion failed: `(left == right)`
  left: `401`,
 right: `400`'

We had to update the program to meet the new requirements. We can use the HttpRequest extractor to access the headers associated with the incoming request:

//! src/routes/newsletters.rs
// [...]
use secrecy::Secret;
use actix_web::http::{HttpRequest, header::HeaderMap};

pub async fn publish_newsletter(
    // [...]
    // New extractor!
    request: HttpRequest,
) -> Result<HttpResponse, PublishError> {
    let _credentials = basic_authentication(request.headers());
    // [...]
}

struct Credentials {
    username: String,
    password: Secret<String>,
}

fn basic_authentication(headers: &HeaderMap) -> Result<Credentials, anyhow::Error> {
    todo!()
}

To extract the credentials, we need to deal with base64 encoding. Let's add the base64 crate as a dependency:

[dependencies]
# [...]
base64 = "0.13"

We can now write basic_authentication:

//! src/routes/newsletters.rs
// [...]

fn basic_authentication(headers: &HeaderMap) -> Result<Credentials, anyhow::Error> {
    // The header value, if present, must be a valid UTF8 string
    let header_value = headers
        .get("Authorization")
        .context("The 'Authorization' header was missing")?
        .to_str()
        .context("The 'Authorization' header was not a valid UTF8 string.")?;
    let base64encoded_segment = header_value
        .strip_prefix("Basic ")
        .context("The authorization scheme was not 'Basic'.")?;
    let decoded_bytes = base64::decode_config(base64encoded_segment, base64::STANDARD)
        .context("Failed to base64-decode 'Basic' credentials.")?;
    let decoded_credentials = String::from_utf8(decoded_bytes)
        .context("The decoded credential string is not valid UTF8.")?;

    // Split into two segments, using ':' as delimitator
    let mut credentials = decoded_credentials.splitn(2, ':');
    let username = credentials
        .next()
        .ok_or_else(|| anyhow::anyhow!("A username must be provided in 'Basic' auth."))?
        .to_string();
    let password = credentials
        .next()
        .ok_or_else(|| anyhow::anyhow!("A password must be provided in 'Basic' auth."))?
        .to_string();

    Ok(Credentials {
        username,
        password: Secret::new(password)
    })
}

Take a moment to step through the code line by line and fully understand what's going on. Many operations that can go wrong!
It will be helpful to open the RFC and compare the contents of this book!

We're not done yet, our tests still fail.
We need to act on the error returned by basic_authentication:

//! src/routes/newsletters.rs
// [...]

#[derive(thiserror::Error)]
pub enum PublishError {
    // New error variant!
    #[error("Authentication failed.")]
    AuthError(#[source] anyhow::Error),
    #[error(transparent)]
    UnexpectedError(#[from] anyhow::Error),
}

impl ResponseError for PublishError {
    fn status_code(&self) -> StatusCode {
        match self {
            PublishError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
            // Return a 401 for auth errors
            PublishError::AuthError(_) => StatusCode::UNAUTHORIZED,
        }
    }
}


pub async fn publish_newsletter(/* */) -> Result<HttpResponse, PublishError> {
    let _credentials = basic_authentication(request.headers())
        // Bubble up the error, performing the necessary conversion
        .map_err(PublishError::AuthError)?;
    // [...]
}

Our status code assertion is happy to pass, but there is still a title missing to complete the second assertion:

thread 'newsletter::requests_missing_authorization_are_rejected' panicked at 
'no entry found for key "WWW-Authenticate"'

So far, it has been sufficient to specify which status code to return for each error. Now we need something more, a title. We need to move the concern ResponseError::status_code from ResponseError::error_response:

//! src/routes/newsletters.rs
// [...]
use actix_web::http::{StatusCode, header};
use actix_web::http::header::{HeaderMap, HeaderValue};

impl ResponseError for PublishError {
    fn error_response(&self) -> HttpResponse {
        match self {
            PublishError::UnexpectedError(_) => {
                HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR)
            }
            PublishError::AuthError(_) => {
                let mut response = HttpResponse::new(StatusCode::UNAUTHORIZED);
                let header_value = HeaderValue::from_str(r#"Basic realm="publish""#)
                    .unwrap();
                response
                    .headers_mut()
                    // actix_web::http::header provides a collection of constants
                    // for the names of several well-known/standard HTTP headers
                    .insert(header::WWW_AUTHENTICATE, header_value);
                response
            }
        }
    }
    
    // `status_code` is invoked by the default `error_response`
    // implementation. We are providing a bespoke `error_response` implementation
    // therefore there is no need to maintain a `status_code` implementation anymore.
}

Our certification test passed! Another part of the code reported an error again:

test newsletter::newsletters_are_not_delivered_to_unconfirmed_subscribers ... FAILED
test newsletter::newsletters_are_delivered_to_confirmed_subscribers ... FAILED

thread 'newsletter::newsletters_are_not_delivered_to_unconfirmed_subscribers' 
panicked at 'assertion failed: `(left == right)`
  left: `401`,
 right: `200`'

thread 'newsletter::newsletters_are_delivered_to_confirmed_subscribers' 
panicked at 'assertion failed: `(left == right)`
  left: `401`,
 right: `200`'

POST /newsletters now rejects all unauthenticated requests, including those we made in our black-box testing. We can stop the bleeding by providing a random combination of username and password:

//! tests/api/helpers.rs
// [...]

impl TestApp {
    pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
        reqwest::Client::new()
            .post(&format!("{}/newsletters", &self.address))
            // Random credentials!
            // `reqwest` does all the encoding/formatting heavy-lifting for us.
            .basic_auth(Uuid::new_v4().to_string(), Some(Uuid::new_v4().to_string()))
            .json(&body)
            .send()
            .await
            .expect("Failed to execute request.")
    }
    
    // [...]
}

The test suite turns green again.

3.2. Password authentication, the naive approach

An authentication layer that accepts random credentials is not ideal.
We need to start validating the credentials we extract from the Authorization header, they should be compared against a list of known users.

We'll create a new usersPostgres table to store this list:

sqlx migrate add create_users_table

A first draft of the architecture might look like this:

-,migrations/20210815112026_create_users_table.sql 
CREATE TABLE users(
   user_id uuid PRIMARY KEY,
   username TEXT NOT NULL UNIQUE,
   password TEXT NOT NULL
);

We can then update our handler to query it every time authentication is performed:

//! src/routes/newsletters.rs
use secrecy::ExposeSecret;
// [...]

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let user_id: Option<_> = sqlx::query!(
        r#"
        SELECT user_id
        FROM users
        WHERE username = $1 AND password = $2
        "#,
        credentials.username,
        credentials.password.expose_secret()
    )
    .fetch_optional(pool)
    .await
    .context("Failed to perform a query to validate auth credentials.")
    .map_err(PublishError::UnexpectedError)?;

    user_id
        .map(|row| row.user_id)
        .ok_or_else(|| anyhow::anyhow!("Invalid username or password."))
        .map_err(PublishError::AuthError)
}

pub async fn publish_newsletter(/* */) -> Result<HttpResponse, PublishError> {
    let credentials = basic_authentication(request.headers())
        .map_err(PublishError::AuthError)?;
    let user_id = validate_credentials(credentials, &pool).await?;
    // [...]
}

It's a good idea to log who is calling POST /newsletters, so let's add a tracing around the handler:

//! src/routes/newsletters.rs
// [...]

#[tracing::instrument(
    name = "Publish a newsletter issue",
    skip(body, pool, email_client, request),
    fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
pub async fn publish_newsletter(/* */) -> Result<HttpResponse, PublishError> {
    let credentials = basic_authentication(request.headers())
        .map_err(PublishError::AuthError)?;
    tracing::Span::current().record(
        "username",
        &tracing::field::display(&credentials.username)
    );
    let user_id = validate_credentials(credentials, &pool).await?;
    tracing::Span::current().record("user_id", &tracing::field::display(&user_id));
    // [...]
}

We now need to update our happy path tests to specify a validate_credentials. We will generate a test user for each instance of our test application. We haven't implemented a signup flow for newsletter editors yet, so we can't take a completely black box approach, we're injecting test user details directly into the database for now:

//! tests/api/helpers.rs
// [...]

pub async fn spawn_app() -> TestApp {
    // [...]

    let test_app = TestApp {/* */};
    add_test_user(&test_app.db_pool).await;
    test_app
}

async fn add_test_user(pool: &PgPool) {
    sqlx::query!(
        "INSERT INTO users (user_id, username, password)
        VALUES ($1, $2, $3)",
        Uuid::new_v4(),
        Uuid::new_v4().to_string(),
        Uuid::new_v4().to_string(),
    )
    .execute(pool)
    .await
    .expect("Failed to create test users.");
}

TestApp will provide a helper method to retrieve its username and password

//! tests/api/helpers.rs
// [...]

impl TestApp {
    // [...]

    pub async fn test_user(&self) -> (String, String) {
        let row = sqlx::query!("SELECT username, password FROM users LIMIT 1",)
            .fetch_one(&self.db_pool)
            .await
            .expect("Failed to create test users.");
        (row.username, row.password)
    }
}

Then we'll call it from our post_newsletters method instead of using random credentials:

//! tests/api/helpers.rs
// [...]

impl TestApp {
    // [...]

    pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
        let (username, password) = self.test_user().await;
        reqwest::Client::new()
            .post(&format!("{}/newsletters", &self.address))
            // No longer randomly generated on the spot!
            .basic_auth(username, Some(password))
            .json(&body)
            .send()
            .await
            .expect("Failed to execute request.")
    }
}

All our tests now pass.

3.3. Password storage

Storing raw user passwords in a database is not a good idea.

An attacker with access to your stored data can immediately start impersonating your users, with usernames and passwords all at the ready.
They don't even have to compromise your live database, an unencrypted backup will suffice.

3.3.1. No need to store the original password

Why do we store passwords in the first place?
We need to perform an equality check, and every time a user tries to authenticate, we verify that the password they provided matches what we expect.

If equality is what we care about, we can start designing a more complex strategy.
For example, we can transform passwords by applying a function before comparing them.

Given the same input, all deterministic functions return the same output.
Let our deterministic function f: psw_candidate == expected_psw imply that f(psw_candidate) == f(expected_psw).
But that's not enough, what if for every possible input string f returned ? hello Password authentication will succeed no matter what input is provided.

We need to go in the opposite direction: if f(psw_candidate) == f(expected_psw) then psw_candidate == expected_psw. This is possible given that our function f has an additional property: it must be injective, if x != ythen f(x) != f(y) .

If we had such a function f, we could avoid storing the original password altogether: when a user signs up, we compute f(password) and store it in our database. password is discarded. When the same user tries to log in, we calculate f(psw_candidate) and check if it matches the f(password) value we stored during registration. The original password is never kept.

Does this really improve our security posture? It depends on f!

It is not difficult to define an injective function, the inverse function f("hello") = "olleh" satisfies our criteria. It's also easy to guess how to reverse the transformation to recover the original password, it doesn't get in the way of an attacker. We can make the transformation more complex, complex enough to make it difficult for an attacker to find the inverse transformation. Even that might not be enough. It is often sufficient for an attacker to be able to recover certain properties of the input (such as length) from the output to implement e.g. a targeted brute force attack. We need something more robust, where there should be no relationship between how similar two inputs are to how similar the corresponding outputs are. x and y do not know each other like f(x) and f(y).

We want a cryptographic hash function.
A hash function maps a string from an input space to a fixed-length output.
The adjective cryptography refers to the consistency property we just discussed, also known as the avalanche effect: small differences in inputs result in outputs so different that they appear uncorrelated.

There is a caveat: the hash function is not injective 2, the risk of collision is small, if f(x) == f(y) there is a high probability (not 100%!) that x == y.

3.3.2. Using cryptographic hashes

Enough theory, let's update our implementation to hash passwords before storing them.

There are several cryptographic hash functions, MD5, SHA-1, SHA-2, SHA-3, KangarooTwelve, etc. We are not going to delve into the pros and cons of each algorithm, when it comes to ciphers, the reason will become clear in a few pages. For the sake of this section, let's move on to SHA-3, the newest member of the family of secure hash algorithms.

On top of the algorithm, we also need to choose the output size, for example SHA3-224 uses the SHA-3 algorithm to produce a fixed-size output of 224 bits. Options are 224, 256, 384 and 512. The longer the output, the less likely we are to encounter a collision. On the other hand, we will need more storage and consume more bandwidth by using longer hashes. SHA3-256 should be more than enough for our use case.

The Rust Crypto organization provides an implementation of SHA-3, crate sha3. Let's add this to our dependencies:

#! Cargo.toml
#! [...]

[dependencies]
# [...]
sha3 = "0.9"

For clarity, let's rename the password column to password_hash :

sqlx migrate add rename_password_column
-,migrations/20210815112028_rename_password_column.sql
ALTER TABLE users RENAME password TO password_hash;

Our project should stop compiling:

error: error returned from database: column "password" does not exist
  --> src/routes/newsletters.rs
   |
90 |       let user_id: Option<_> = sqlx::query!(
   |  ______________________________^
91 | |         r#"
92 | |         SELECT user_id
93 | |         FROM users
...  |
97 | |         credentials.password
98 | |     )
   | |_____^

sqlx::query! found that one of our queries is using a column that no longer exists in the current schema. Compile-time validation of SQL queries is pretty neat, isn't it?

Our validate_credentials function looks like this:

//! src/routes/newsletters.rs
//! [...]

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let user_id: Option<_> = sqlx::query!(
        r#"
        SELECT user_id
        FROM users
        WHERE username = $1 AND password = $2
        "#,
        credentials.username,
        credentials.password.expose_secret()
    )
    // [...]
}

Let's update it to use hashed passwords:

//! src/routes/newsletters.rs
//! [...]
use sha3::Digest;

async fn validate_credentials(/* */) -> Result<uuid::Uuid, PublishError> {
    let password_hash = sha3::Sha3_256::digest(
        credentials.password.expose_secret().as_bytes()
    );
    let user_id: Option<_> = sqlx::query!(
        r#"
        SELECT user_id
        FROM users
        WHERE username = $1 AND password_hash = $2
        "#,
        credentials.username,
        password_hash
    )
    // [...]
}

Unfortunately, it doesn't compile right away:

error[E0308]: mismatched types
  --> src/routes/newsletters.rs:99:9
   |
99 |         password_hash
   |         ^^^^^^^^^^^^^ expected `&str`, found struct `GenericArray`
   |
   = note: expected reference `&str`
                 found struct `GenericArray<u8, UInt<..>>`

Digest::digest returns a fixed-length byte array, and our password_hash column is of type TEXT string.
We can change the schema of the users table to store password_hash as binary. Alternatively, we can encode the bytes returned by Digest::digest into a string using hexadecimal format.

Let's avoid another migration by using the second option:

//! [...]

async fn validate_credentials(/* */) -> Result<uuid::Uuid, PublishError> {
    let password_hash = sha3::Sha3_256::digest(
        credentials.password.expose_secret().as_bytes()
    );
    // Lowercase hexadecimal encoding.
    let password_hash = format!("{:x}", password_hash);
    // [...]
}

The application code should now compile. Instead, the test suite requires more work. The helper method is to query the table via test_user to recover a valid set of credentialed users, now that we store hashes instead of raw passwords, this is no longer possible!

//! tests/api/helpers.rs
//! [...]
 
impl TestApp {
    // [...]
    
    pub async fn test_user(&self) -> (String, String) {
        let row = sqlx::query!("SELECT username, password FROM users LIMIT 1",)
            .fetch_one(&self.db_pool)
            .await
            .expect("Failed to create test users.");
        (row.username, row.password)
    }
}

pub async fn spawn_app() -> TestApp {
    // [...]
    let test_app = TestApp {/* */};
    add_test_user(&test_app.db_pool).await;
    test_app
}

async fn add_test_user(pool: &PgPool) {
    sqlx::query!(
        "INSERT INTO users (user_id, username, password)
        VALUES ($1, $2, $3)",
        Uuid::new_v4(),
        Uuid::new_v4().to_string(),
        Uuid::new_v4().to_string(),
    )
    .execute(pool)
    .await
    .expect("Failed to create test users.");
}

We need TestApp to store the randomly generated password so we can access it in the helper method. Let's start by creating a new helper structure, TestUser :

//! tests/api/helpers.rs
//! [...]
use sha3::Digest;

pub struct TestUser {
    pub user_id: Uuid,
    pub username: String,
    pub password: String
}

impl TestUser {
    pub fn generate() -> Self {
        Self {
            user_id: Uuid::new_v4(),
            username: Uuid::new_v4().to_string(),
            password: Uuid::new_v4().to_string()
        }
    }

    async fn store(&self, pool: &PgPool) {
        let password_hash = sha3::Sha3_256::digest(
            credentials.password.expose_secret().as_bytes()
        );
        let password_hash = format!("{:x}", password_hash);
        sqlx::query!(
            "INSERT INTO users (user_id, username, password_hash)
            VALUES ($1, $2, $3)",
            self.user_id,
            self.username,
            password_hash,
        )
        .execute(pool)
        .await
        .expect("Failed to store test user.");
    }
}

Then we can attach an instance of TestUserto, TestApp , as a new field:

//! tests/api/helpers.rs
//! [...]

pub struct TestApp {
    // [...]
    test_user: TestUser
}

pub async fn spawn_app() -> TestApp {
    // [...]
    let test_app = TestApp {
        // [...]
        test_user: TestUser::generate()
    };
    test_app.test_user.store(&test_app.db_pool).await;
    test_app
}

Finally, let's remove add_test_user and update TestApp::post_newsletters for TestApp::test_user :

//! tests/api/helpers.rs
//! [...]

impl TestApp {
    // [..]
    pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
        reqwest::Client::new()
            .post(&format!("{}/newsletters", &self.address))
            .basic_auth(&self.test_user.username, Some(&self.test_user.password))
            // [...]
    }
}

The test suite should now compile and run successfully.

3.3.3. Preimage attack

If an attacker gets hold of our table, is SHA3-256 enough to protect our user's password users?

Let's assume the attack wants to crack a specific password hash in our database. The attacker doesn't even need to retrieve the original password. In order to successfully authenticate, they just need to find an input string whose sSHA3-256 hash matches the password they are trying to crack, in other words, a collision.
This is called a preimage attack.

How difficult is it?

The math is a bit tricky, but a brute force attack has exponential time complexity 2^n, where n is the hash length in bits. If n > 128, the computation is considered infeasible. Unless a vulnerability is found in SHA-3, we don't need to worry about preimage attacks against SHA3-256.

3.3.4. Naive dictionary attack

We won't be hashing arbitrary input though, we can reduce the search space by making some assumptions about the original password: how long is it? What symbols are used? Let's say we're looking for an alphanumeric password with less than 17 characters3. 2

We can count the number of candidate passwords:

// (26 letters + 10 number symbols) ^ Password Length
// for all allowed password lengths
36^1 +
36^2 +
... +
36^16

It summarizes roughly 8*10^24 possibilities. I couldn't find data specifically about SHA3-256, but the researchers managed to calculate about 900 million SHA3-512 hashes per second using a graphics processing unit (GPU).

Assuming a hash rate of ~10^9 per second, we need ~10^15 seconds to hash all candidate passwords. The approximate age of the universe is 4 * 10^17 seconds.
Even if we parallelize our search using 1 million GPUs, it still takes ~10^9 seconds, about 30 years. 3

3.3.5. Dictionary Attack

Returning to what we discussed at the beginning of this chapter, it is impossible for a single person to remember hundreds of unique passwords for online services.
They either rely on password managers or reuse one or more passwords across multiple accounts.

Also, even with repeated use, most passwords are far from random, common words, full names, dates, names of popular sports teams, etc. Attackers can easily design a simple algorithm to generate thousands of plausible passwords, and they can try to attack by finding the most common passwords from the password data sets of numerous security breaches in the past decade.

They can precompute the SHA3-256 hashes of the top 10 million passwords in minutes. Then they start scanning our database for matches.

This is called a dictionary attack, and it's very effective.

All the cryptographic hash functions we have mentioned so far are designed to be fast. Fast enough that anyone can perform a dictionary attack without using specialized hardware.

We need something that is much slower, but has the same set of mathematical properties as a cryptographic hash function.

3.3.6. Aragon2

The Open Web Application Security Project (OWASP)  4 has a useful guide on secure password storage, with a whole section on how to choose the right hashing algorithm:

https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html

Using Argon2id, the minimum configuration is 15MiB of memory, the number of iterations is 2, and the degree of parallelism is 1.
If Argon2id is not available, use bcrypt with a work factor of 10 or higher and a password limit of 72 bytes.
For older systems using scrypt, use minimum CPU/memory cost parameter (2^16), minimum block size 8 (1024 bytes) and parallelization parameter 1.
If FIPS-140 compliance is required, use PBKDF2 with a work factor of 310,000 or higher, set with the internal hash function of HMAC-SHA-256.
Consider using chili to provide additional defense in depth (although used alone, it provides no additional security features).

All of these options, Argon2, bcrypt, scrypt, PBKDF2, are designed to be computationally demanding.
They also expose configuration parameters (such as bcrypt's work factor) to further slow down hash computations: application developers can tweak some knobs to keep up with hardware acceleration, without migrating to newer algorithms every few years.

As suggested by OWASP, let's replace SHA-3 with Argon2id.
The Rust Crypto organization has again provided a pure Rust implementation of argon2. 5

Let's add this to our dependencies:

#! Cargo.toml
#! [...]

[dependencies]
# [...]
argon2 = { version = "0.4", features = ["std"] }

To hash passwords, we need to create an Argon2 structure instance. The method signature looks like this

//! argon2/lib.rs
/// [...]
 
impl<'key> Argon2<'key> {
    /// Create a new Argon2 context.
    pub fn new(algorithm: Algorithm, version: Version, params: Params) -> Self {
        // [...]
    }
    // [...]
}

Algorithm is an enumeration: it lets us choose which variant of Argon2 to use, Argon2d, Argon2i, Argon2id. To comply with OWASP recommendations, we will choose Algorith::Argon2id.

Version achieves a similar purpose, and we'll choose the most recent, Version::V0x13 .

Params,Params::new specifies all the mandatory parameters we need to supply to build one.

//! argon2/params.rs
// [...]

/// Create new parameters.
pub fn new(
    m_cost: u32, 
    t_cost: u32, 
    p_cost: u32, 
    output_len: Option<usize>
) -> Result<Self> {
    // [...]
}

m_cost, t_cost and p_cost map to OWASP requirements:

  • m_cost is the memory size, expressed in kilobytes
  • t_cost is the number of iterations;
  • p_cost is the degree of parallelism.
  • output_len, in contrast, determines the length of the returned hash. If omitted, it will default to 32 bytes. This equals 256 bits, the same length as the hash we get with SHA3-256.

At this point, we know enough to build one:

//! src/routes/newsletters.rs
use argon2::{Algorithm, Argon2, Version, Params};
// [...]

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let hasher = Argon2::new(
        Algorithm::Argon2id,
        Version::V0x13,
        Params::new(15000, 2, 1, None)
            .context("Failed to build Argon2 parameters")
            .map_err(PublishError::UnexpectedError)?,
    );
    let password_hash = sha3::Sha3_256::digest(
        credentials.password.expose_secret().as_bytes()
    );
   // [...]
}

Argon2 implements the PasswordHasher trait:

//! password_hash/traits.rs

pub trait PasswordHasher {
    // [...]
    fn hash_password<'a, S>(
        &self, 
        password: &[u8], 
        salt: &'a S
    ) -> Result<PasswordHash<'a>>
    where
        S: AsRef<str> + ?Sized;
}

password-hash is the crate's re-exported unified interface for handling password hashes supported by various algorithms (currently Argon2, PBKDF2, and scrypt).

PasswordHasher::hash_password is a bit different from Sha3_256::digest, which requires an additional parameter salt on top of the original password.

3.3.7. Salt

Argon2 is much slower than SHA-3, but not enough to make dictionary attacks infeasible. It will take longer to hash the 10 million most common passwords, but not by much.

But what if an attacker has to rehash the entire dictionary for every user in our database? It gets more challenging!

This is what adding salt does. For each user, we generate a unique random string, the salt. The salt is prepended to the user's password before the hash is generated. PasswordHasher::hash_password handles the pre-transaction for us.

The salt is stored next to the password hash in our database. If an attacker obtains a database backup, they will have access to all salts. 6  but they have to calculate dictionary_size * n_users hash instead of dictionary_size. Also, precomputing hashes is no longer an option, which buys us time to detect breaches and take action (for example, force password resets for all users).

Let's add a password_salt column to the users table:

//! src/routes/newsletters.rs
// [...]
use argon2::PasswordHasher;

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let hasher = argon2::Argon2::new(/* */);
    let row: Option<_> = sqlx::query!(
        r#"
        SELECT user_id, password_hash, salt
        FROM users
        WHERE username = $1
        "#,
        credentials.username,
    )
    .fetch_optional(pool)
    .await
    .context("Failed to perform a query to retrieve stored credentials.")
    .map_err(PublishError::UnexpectedError)?;

    let (expected_password_hash, user_id, salt) = match row {
        Some(row) => (row.password_hash, row.user_id, row.salt),
        None => {
            return Err(PublishError::AuthError(anyhow::anyhow!(
                "Unknown username."
            )));
        }
    };

    let password_hash = hasher
        .hash_password(
            credentials.password.expose_secret().as_bytes(),
            &salt
        )
        .context("Failed to hash password")
        .map_err(PublishError::UnexpectedError)?;
    
    let password_hash = format!("{:x}", password_hash.hash.unwrap());

    if password_hash != expected_password_hash {
        Err(PublishError::AuthError(anyhow::anyhow!(
            "Invalid password."
        )))
    } else {
        Ok(user_id)
    }
}

Unfortunately, fails to compile:

error[E0277]: the trait bound 
`argon2::password_hash::Output: LowerHex` is not satisfied
   --> src/routes/newsletters.rs
    |
125 |     let password_hash = format!("{:x}", password_hash.hash.unwrap());
    |                                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 
    the trait `LowerHex` is not implemented for `argon2::password_hash::Output`

Output provides other methods to obtain string representations. For example output ::b64_encode. As long as we are happy to change the assumed encoding of the hash stored in the database, it will work.

Given the necessary changes, we can look for something better than base64 encoding.

3.3.8. PHC String Format

In order to authenticate users, we need repeatability: we have to run the same hash routine every time. Salts and passwords are only a subset of Argon2id's inputs. All other payload parameters (t_cost, m_cost, p_cost) are equally important to obtain the same hash value given the same salt and password pair.

If we store the base64-encoded representation of the hash, we make a strong implicit assumption: all values ​​stored in the password_hash column were computed using the same load parameters.

As we discussed in previous sections, hardware capabilities evolve over time: application developers need to keep up by increasing the computational cost of hashing with higher load parameters. What happens when you have to migrate your stored passwords to a newer hash configuration?

In order to continue authenticating old users, we must store next to each hash the exact set of payload parameters used to compute it. This allows for seamless migration between two different load configurations: when the old user authenticates, we verify password validity using the stored load parameters; we then recompute the password hashes using the new load parameters and Update stored information.

We can take the easy way out and add three new columns to our user table: t_cost, m_cost, and p_cost. As long as the algorithm remains Argon2id, it will work.

What would happen if a bug was found in Argon2id and we were forced to migrate away from it? We might want to add an algorithm column, as well as a new column for storing the load parameter that Argon2id replaces.

It can be done, but tedious. Fortunately, there is a better solution: the PHC string format. The PHC string format provides a standard representation for a cryptographic hash: it includes the hash itself, the salt, the algorithm and all its associated parameters.

Using the PHC string format, the Argon2id password hash looks like this:

# ${algorithm}${algorithm version}${$-separated algorithm parameters}${hash}${salt}
$argon2id$v=19$m=65536,t=2,p=1$gZiV/M1gPc22ElAH/Jh1Hw$CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno

The argon2 crate exposes PasswordHash, a Rust implementation in PHC format:

//! argon2/lib.rs
// [...]

pub struct PasswordHash<'a> {
    pub algorithm: Ident<'a>,
    pub version: Option<Decimal>,
    pub params: ParamsString,
    pub salt: Option<Salt<'a>>,
    pub hash: Option<Output>,
}

Storing password hashes in PHC string format saves us from having Argon2 initialize the structure with an explicit parameter7. We can rely on the implementation of trait Argon2: PasswordVerifier  7

pub trait PasswordVerifier {
    fn verify_password(
        &self,
        password: &[u8],
        hash: &PasswordHash<'_>
    ) -> Result<()>;
}

By passing the expected hash PasswordHash, Argon2 can automatically deduce what payload parameter and salt should be used to verify that the candidate password matches8

Let's update our implementation:

//! src/routes/newsletters.rs
use argon2::{Argon2, PasswordHash, PasswordVerifier};
// [...]

async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let row: Option<_> = sqlx::query!(
        r#"
        SELECT user_id, password_hash
        FROM users
        WHERE username = $1
        "#,
        credentials.username,
    )
    .fetch_optional(pool)
    .await
    .context("Failed to perform a query to retrieve stored credentials.")
    .map_err(PublishError::UnexpectedError)?;

    let (expected_password_hash,user_id) = match row {
        Some(row) => (row.password_hash,row.user_id),
        None => {
            return Err(PublishError::AuthError(anyhow::anyhow!(
                "Unknown username."
            )))
        }
    };

    let expected_password_hash = PasswordHash::new(&expected_password_hash)
        .context("Failed to parse hash in PHC string format.")
        .map_err(PublishError::UnexpectedError)?;

    Argon2::default()
        .verify_password(
             credentials.password.expose_secret().as_bytes(), 
             &expected_password_hash
        )
        .context("Invalid password.")
        .map_err(PublishError::AuthError)?;

    Ok(user_id)
}

It compiles successfully. You might also notice that we no longer deal with the salt directly, the PHC string format handles it for us implicitly. We can get rid of the salt column entirely:

sqlx migrate add remove_salt_from_users
-,migrations/20210815112222_remove_salt_from_users.sql 
ALTER TABLE users DROP COLUMN salt;

What about our test? Two of them fail:

---,newsletter::newsletters_are_not_delivered_to_unconfirmed_subscribers stdout ----
'newsletter::newsletters_are_not_delivered_to_unconfirmed_subscribers' panicked at 
'assertion failed: `(left == right)`
  left: `500`,
 right: `200`',

---,newsletter::newsletters_are_delivered_to_confirmed_subscribers stdout ----
'newsletter::newsletters_are_delivered_to_confirmed_subscribers' panicked at 
'assertion failed: `(left == right)`
  left: `500`,

We can look at the logs to figure out what the problem is:

TEST_LOG=true cargo t newsletters_are_not_delivered | bunyan
[2021-08-29T20:14:50.367Z] ERROR: [HTTP REQUEST,EVENT] 
  Error encountered while processing the incoming HTTP request: 
  Failed to parse hash in PHC string format.

  Caused by:
     password hash string invalid

Let's look at our test user's password generation code:

//! tests/api/helpers.rs
// [...]

impl TestUser {
    // [...]
    async fn store(&self, pool: &PgPool) {
        let password_hash = sha3::Sha3_256::digest(
            credentials.password.expose_secret().as_bytes()
        );
        let password_hash = format!("{:x}", password_hash);
        // [...]
    }
}

We're still using SHA-3! Let's update it:

//! tests/api/helpers.rs
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHasher};
// [...]

impl TestUser {
    // [...]
    async fn store(&self, pool: &PgPool) {
        let salt = SaltString::generate(&mut rand::thread_rng());
        // We don't care about the exact Argon2 parameters here
        // given that it's for testing purposes!
        let password_hash = Argon2::default()
            .hash_password(self.password.as_bytes(), &salt)
            .unwrap()
            .to_string();
        // [...]
    }
}

The test suite should now pass. We've removed all mention of sha3 from our project, we can now remove it from the list of dependencies in Cargo.toml.

3.4. Don't block asynchronous executors

How long does it take to verify user credentials when running our integration tests? We currently don't have a trace around password hashes, let's fix that:

//! src/routes/newsletters.rs
// [...]

#[tracing::instrument(name = "Validate credentials", skip(credentials, pool))]
async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let (user_id, expected_password_hash) = get_stored_credentials(
            &credentials.username, 
            &pool
        )
        .await
        .map_err(PublishError::UnexpectedError)?
        .ok_or_else(|| PublishError::AuthError(anyhow::anyhow!("Unknown username.")))?;

    let expected_password_hash = PasswordHash::new(
            &expected_password_hash.expose_secret()
        )
        .context("Failed to parse hash in PHC string format.")
        .map_err(PublishError::UnexpectedError)?;

    tracing::info_span!("Verify password hash")
        .in_scope(|| {
            Argon2::default()
                .verify_password(
                    credentials.password.expose_secret().as_bytes(), 
                    expected_password_hash
                )
        })
        .context("Invalid password.")
        .map_err(PublishError::AuthError)?;

    Ok(user_id)
}

// We extracted the db-querying logic in its own function with its own span.
#[tracing::instrument(name = "Get stored credentials", skip(username, pool))]
async fn get_stored_credentials(
    username: &str,
    pool: &PgPool,
) -> Result<Option<(uuid::Uuid, Secret<String>)>, anyhow::Error> {
    let row = sqlx::query!(
        r#"
        SELECT user_id, password_hash
        FROM users
        WHERE username = $1
        "#,
        username,
    )
    .fetch_optional(pool)
    .await
    .context("Failed to perform a query to retrieve stored credentials.")?
    .map(|row| (row.user_id, Secret::new(row.password_hash)));
    Ok(row)
}

We can now view the logs of one of the integration tests:

TEST_LOG=true cargo test --quiet --release \
  newsletters_are_delivered | grep "VERIFY PASSWORD" | bunyan
[...]  [VERIFY PASSWORD HASH,END] (elapsed_milliseconds=11, ...)

About 10 milliseconds. This can cause load problems, the notorious blocking problem.

async/await in Rust is built around a concept called cooperative dispatch.

How does it work? Let's look at an example:

async fn my_fn() {
    a().await;
    b().await;
    c().await;
}

my_fn returns a future. When awaiting the future, our async runtime (tokio) comes into the picture: it starts polling it.

How does the Future returned by my_fn implement poll? You can think of it as a state machine:

enum MyFnFuture {
    Initialized,
    CallingA,
    CallingB,
    CallingC,
    Complete
}

Every time poll is called, it tries to make progress by reaching the next state. For example if a.await() returns, we start waiting on b()  9 .

For each .await in the async function body, we have a different state in MyFnFuture. That's why .await calls are often named yield points, where our future advances from the previous .await to the next before returning control to the executor.

The executor can then choose to poll the same future again, or prioritize making progress on another task. This is how asynchronous runtimes such as tokio manage to make progress on multiple tasks simultaneously, by constantly parking and resuming each task. In a way, you can think of the asynchronous runtime as a great juggler.

The basic assumption is that most asynchronous tasks are doing some kind of input-output (IO) work, and most of their execution time will be spent waiting for other things to happen (for example, the operating system notifies us that data is available to read on a socket ), so we can efficiently execute many more tasks simultaneously than we can achieve by assigning each task a parallel execution unit (e.g. one thread per operating system core).

Assuming that tasks cooperate by frequently handing control back to executors, this model works very well. In other words, poll is expected to be fast, it should return 10 in less than 10-100 microseconds . If calling poll takes longer (or worse, never returns), then the async executor can't make progress on any other tasks, which is what people mean when they say "the task is blocking the executor/async thread" means".

You should always be aware of CPU-intensive workloads that may take more than 1ms, password hashing is a good example. In order to use tokio better, we must use tokio::task::spawn_blocking. These threads are reserved for blocking operations and will not interfere with the scheduling of asynchronous tasks.

Let's get to work!

//! src/routes/newsletters.rs
// [...]

#[tracing::instrument(name = "Validate credentials", skip(credentials, pool))]
async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    // [...]
    tokio::task::spawn_blocking(move || {
        tracing::info_span!("Verify password hash").in_scope(|| {
            Argon2::default()
                .verify_password(
                    credentials.password.expose_secret().as_bytes(), 
                    &expected_password_hash)
        })
    })
    .await
    // spawn_blocking is fallible,we have a nested Result here!
    .context("Failed to spawn blocking task.")
    .map_err(PublishError::UnexpectedError)?
    .context("Invalid password.")
    .map_err(PublishError::AuthError)?;
    // [...]
}

Borrow check complaints:

error[E0597]: `expected_password_hash` does not live long enough
   --> src/routes/newsletters.rs
    |
117 |     PasswordHash::new(&expected_password_hash)
    |     ------------------^^^^^^^^^^^^^^^^^^^^^^^-
    |     |                 |
    |     |                 borrowed value does not live long enough
    |     argument requires that `expected_password_hash` is borrowed for `'static`
...
134 | }
    |,`expected_password_hash` dropped here while still borrowed

We're launching a computation on a separate thread, and the thread itself may outlive the async task we spawned it from. To avoid this problem, spawn_blocking requires its arguments to have a 'static lifetime, which prevents us from passing a reference to the current function context into the closure.

You could argue, "we're using move || {} , the closure should have the expected_password_hash!". you are right! But that's not enough. Let's take a look at how PasswordHash is defined:

pub struct PasswordHash<'a> {
    pub algorithm: Ident<'a>,
    pub salt: Option<Salt<'a>>,
    // [...]
}

It contains a reference to the string it parses. We need to move the ownership of the original string into our closure, and move the parsing logic into it as well.

For clarity, let's create a separate function, verify_password_hash :

//! src/routes/newsletters.rs
// [...]

#[tracing::instrument(name = "Validate credentials", skip(credentials, pool))]
async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    // [...]
    tokio::task::spawn_blocking(move || {
        verify_password_hash(
            expected_password_hash, 
            credentials.password
        )
    })
    .await
    .context("Failed to spawn blocking task.")
    .map_err(PublishError::UnexpectedError)??;

    Ok(user_id)
}

#[tracing::instrument(
    name = "Verify password hash", 
    skip(expected_password_hash, password_candidate)
)]
fn verify_password_hash(
    expected_password_hash: Secret<String>,
    password_candidate: Secret<String>,
) -> Result<(), PublishError> {
    let expected_password_hash = PasswordHash::new(
            expected_password_hash.expose_secret()
        )
        .context("Failed to parse hash in PHC string format.")
        .map_err(PublishError::UnexpectedError)?;

    Argon2::default()
        .verify_password(
            password_candidate.expose_secret().as_bytes(),
            &expected_password_hash
        )
        .context("Invalid password.")
        .map_err(PublishError::AuthError)
}

compile ok!

3.4.1. The trace context is thread-local

Let's check the verify password hashspan log again:

TEST_LOG=true cargo test --quiet --release \
  newsletters_are_delivered | grep "VERIFY PASSWORD" | bunyan
[2021-08-30T10:03:07.613Z]  [VERIFY PASSWORD HASH,START] 
  (file="...", line="...", target="...")
[2021-08-30T10:03:07.624Z]  [VERIFY PASSWORD HASH,END]
  (file="...", line="...", target="...")

We are missing all the attributes inherited from the corresponding request's root trace, such as request_id, http.method, http.route, etc. Why?

Let's look at tracing's documentation:

Spans form a tree structure, and unless it is the root span, all spans have a parent and possibly one or more children. When a new trace is created, the current trace becomes the parent of the new trace.

The current trace is the one returned by tracing::Span::current() , let's check its documentation:

Returns a handle to the trace that is considered the Collector to be the current trace.

If the collector indicates that it is not currently tracing a trace, or if the thread calling this function is not currently within a trace, the trace returned will be disabled.

"Current trace" actually means "active trace for the current thread". That's why we don't inherit any properties: we spawn our computation on a separate thread, and tracing::info_span! didn't find any activity associated with it while it was executing. Span

We can fix this by explicitly attaching the current trace to the newly spawned thread:

//! src/routes/newsletters.rs
// [...]

#[tracing::instrument(name = "Validate credentials", skip(credentials, pool))]
async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    // [...]
    // This executes before spawning the new thread
    let current_span = tracing::Span::current();
    tokio::task::spawn_blocking(move || {
        // We then pass ownership to it into the closure
        // and explicitly executes all our computation
        // within its scope.
        current_span.in_scope(|| {
            verify_password_hash(/* */)
        })
    })
    // [...]
}

You can verify that it works, we are now fetching all the properties we care about. Although a bit verbose, let's write a helper function:

//! src/telemetry.rs
use tokio::task::JoinHandle;
// [...]

// Just copied trait bounds and signature from `spawn_blocking`
pub fn spawn_blocking_with_tracing<F, R>(f: F) -> JoinHandle<R>
where
    F: FnOnce() -> R + Send + 'static,
    R: Send + 'static,
{
    let current_span = tracing::Span::current();
    tokio::task::spawn_blocking(move || current_span.in_scope(f))
}
//! src/routes/newsletters.rs
use crate::telemetry::spawn_blocking_with_tracing;
// [...]

#[tracing::instrument(name = "Validate credentials", skip(credentials, pool))]
async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    // [...]
    spawn_blocking_with_tracing(move || {
        verify_password_hash(/* */)
    })
    // [...]
}

Now we can easily use it whenever we need to offload some CPU intensive calculations to a dedicated thread pool.

3.5. User Enumeration

Let's add a new test case:

//! tests/api/newsletter.rs
use uuid::Uuid;
// [...]

#[tokio::test]
async fn non_existing_user_is_rejected() {
    // Arrange
    let app = spawn_app().await;
    // Random credentials
    let username = Uuid::new_v4().to_string();
    let password = Uuid::new_v4().to_string();

    let response = reqwest::Client::new()
        .post(&format!("{}/newsletters", &app.address))
        .basic_auth(username, Some(password))
        .json(&serde_json::json!({
            "title": "Newsletter title",
            "content": {
                "text": "Newsletter body as plain text",
                "html": "<p>Newsletter body as HTML</p>",
            }
        }))
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert_eq!(401, response.status().as_u16());
    assert_eq!(
        r#"Basic realm="publish""#,
        response.headers()["WWW-Authenticate"]
    );
}

The test should pass immediately. But how long?

Let's look at the logs!

TEST_LOG=true cargo test --quiet --release \
  non_existing_user_is_rejected | grep "HTTP REQUEST" | bunyan
# [...] Omitting setup requests
[...] [HTTP REQUEST,END]
  (http.route = "/newsletters", elapsed_milliseconds=1, ...)

About 1ms.

Let's add another test: this time we pass a valid username and a bad password.

//! tests/api/newsletter.rs
// [...]

#[tokio::test]
async fn invalid_password_is_rejected() {
    // Arrange
    let app = spawn_app().await;
    let username = &app.test_user.username;
    // Random password
    let password = Uuid::new_v4().to_string();
    assert_ne!(app.test_user.password, password);

    let response = reqwest::Client::new()
        .post(&format!("{}/newsletters", &app.address))
        .basic_auth(username, Some(password))
        .json(&serde_json::json!({
            "title": "Newsletter title",
            "content": {
                "text": "Newsletter body as plain text",
                "html": "<p>Newsletter body as HTML</p>",
            }
        }))
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert_eq!(401, response.status().as_u16());
    assert_eq!(
        r#"Basic realm="publish""#,
        response.headers()["WWW-Authenticate"]
    );
}

This should also pass. How long does it take for the request to fail?

TEST_LOG=true cargo test --quiet --release \
  invalid_password_is_rejected | grep "HTTP REQUEST" | bunyan
# [...] Omitting setup requests
[...] [HTTP REQUEST,END]
  (http.route = "/newsletters", elapsed_milliseconds=11, ...)

At about 10 milliseconds, it's an order of magnitude smaller! We can exploit this difference to perform timing attacks, which are members of the broader class of side-channel attacks.

If an attacker knows at least one valid username, they can check the server response time 11 to see if another username exists. We are investigating a potential user enumeration vulnerability. Is this a problem?

It depends, if you're running Gmail, there are plenty of other ways to determine if an @gmail.com email address exists. The validity of email addresses is no secret!

If you're running a SaaS product, the situation can be more subtle. Let's assume a hypothetical scenario: Your SaaS product offers a payroll service and uses email addresses as usernames. There are separate login pages for staff and admins. My goal is to access payroll data and I need to compromise an employee with privileged access. We can scrape LinkedIn to get the first and last names of all employees in the finance department. Corporate emails follow a predictable structure ( [email protected] ), so we have a list of candidates. We can now perform a timing attack on the admin login page to narrow down the list to those who have access.

Even in our fictional example, user enumeration alone is not enough to elevate our privileges. But it can serve as a stepping stone to narrow down a set of targets for a more precise attack.

How can we prevent it? Two strategies:

  • Remove the time difference between authentication failures caused by invalid passwords and authentication failures caused by non-existent usernames;
  • Limit the number of failed authentication attempts for a given IP/username. The second is often valuable as protection against brute-force attacks, but it needs to be kept in some state, which we'll leave for later.

Let's focus on the first one. To remove the timing difference, we need to perform the same amount of work in both cases.

Now, we follow this recipe:

Get the stored credentials for the given username; return a 401 if they don't exist; if they do exist, hash the candidate password and compare to the stored hash. We need to remove that early exit and we should have a fallback expected password (with salt and load parameters) that can be compared to the hash of the password candidate.

//! src/routes/newsletters.rs
// [...]

#[tracing::instrument(name = "Validate credentials", skip(credentials, pool))]
async fn validate_credentials(
    credentials: Credentials,
    pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
    let mut user_id = None;
    let mut expected_password_hash = Secret::new(
        "$argon2id$v=19$m=15000,t=2,p=1$\
        gZiV/M1gPc22ElAH/Jh1Hw$\
        CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
            .to_string()
    );

   if let Some((stored_user_id, stored_password_hash)) =
        get_stored_credentials(&credentials.username, &pool)
            .await
            .map_err(PublishError::UnexpectedError)?
    {
        user_id = Some(stored_user_id);
        expected_password_hash = stored_password_hash;
    }

    spawn_blocking_with_tracing(move || {
        verify_password_hash(expected_password_hash, credentials.password)
    })
    .await
    .context("Failed to spawn blocking task.")
    .map_err(PublishError::UnexpectedError)??;

    // This is only set to `Some` if we found credentials in the store
    // So, even if the default password ends up matching (somehow)
    // with the provided password, 
    // we never authenticate a non-existing user.
    // You can easily add a unit test for that precise scenario.
    user_id.ok_or_else(|| 
        PublishError::AuthError(anyhow::anyhow!("Unknown username."))
    )
}
//! tests/api/helpers.rs
use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version};
// [...]

impl TestUser {
    async fn store(&self, pool: &PgPool) {
        let salt = SaltString::generate(&mut rand::thread_rng());
        // Match parameters of the default password
        let password_hash = Argon2::new(
            Algorithm::Argon2id,
            Version::V0x13,
            Params::new(15000, 2, 1, None).unwrap(),
        )
        .hash_password(self.password.as_bytes(), &salt)
        .unwrap()
        .to_string();
        // [...]
    }
    // [...]
}

Now there shouldn't be any statistically significant time differences.

4. Is it safe?

We've made every effort to follow all of the most common best practices when building password-based authentication flows. Time to ask yourself: is it safe?

4.1. Transport Layer Security (TLS)

We've made every effort to follow all of the most common best practices when building password-based authentication flows. Time to ask yourself: is it safe?

We pass credentials between client and server using the "basic" authentication scheme, username and password are encoded, but not encrypted. We must use Transport Layer Security (TLS) to ensure that no one can eavesdrop on the traffic between the client and server to compromise user credentials (man-in-the-middle attack, MITM) 12 . Our API is already served over HTTPS, so there's nothing to do here.

4.2. Reset password

What happens if an attacker manages to steal a valid set of user credentials? Passwords do not expire, they are long-lived secrets.

Currently, users cannot reset their passwords. This is definitely a void that we need to fill.

4.3. Interaction type

So far, we've been pretty vague about who is calling our API.

When it comes to authentication, the type of interaction we need to support is a key decision factor.

We'll look at three types of callers:

Other API (machine-to-machine); one person, via browser; another API, on behalf of a person.

4.4. Machine to Machine

A consumer of your API might be a machine (eg another API). This is often the case in microservice architectures, where your functionality comes from various services interacting over the network.

To significantly improve our security profile, we'd have to either incorporate something they have (such as request signing) or something they have (such as IP range restrictions). When all services are owned by the same organization, a popular choice is mutual TLS (mTLS).

Both signing and mTLS rely on public key cryptography, and keys must be provisioned, rotated, and managed. The overhead is only justified when the system reaches a certain size.

4.4.1. Client Credentials via OAuth2

Another option is to use the OAuth2 client credentials flow. We'll talk more about OAuth2 later, but let's talk about its tactical pros and cons.

APIs no longer need to manage passwords (client secrets, in OAuth2 terms), this problem is delegated to a centralized authorization server. There are several turnkey implementations of authorization servers, OSS and commercial. You can rely on them instead of rolling your own.

The caller authenticates to the authorization server, and if successful, the authentication server grants them a set of temporary credentials (JWT access tokens) that can be used to call our API. Our API can use public key cryptography to verify the validity of access tokens without retaining any state. Our API never sees the actual secret, the client secret.

JWT validation is not without risks, and the specification is full of dangerous edge cases. We'll talk more about this later.

4.5. Human via browser

What if we interacted with people using a web browser?

"Basic" authentication requires clients to supply their credentials with every request. We have a protected endpoint right now, but you could easily picture a five-page or ten-page situation that provides privileged functionality. As it stands, "Basic" authentication will force users to submit their credentials on every page. Not very good.

We need a way to remember that the user was authenticated moments ago, i.e. attach some state to a series of requests from the same browser. This is done using sessions.

The user is asked to authenticate once via the login form 13 : if successful, the server generates a one-time secret, an authenticated session token. The token is stored in the browser as a secure cookie. Sessions, unlike passwords, are designed to expire, which reduces the chance of valid session tokens being compromised (especially if inactive users are automatically logged out). It also prevents users from having to reset their password if they suspect their session has been hijacked, making a forced logout more acceptable than an automated password reset.

This approach is often referred to as session-based authentication.

4.5.1. Federated Identity

With session-based authentication, we still need to handle one authentication step, the login form. We can keep rolling our own, and everything we've learned about passwords will still be relevant, even if we drop the "basic" authentication scheme.

Many websites choose to offer their users an additional option: sign in via social profiles, such as "Sign in with Google". This removes friction from the signup process (no need to create another password!), increases conversions, and a desirable outcome.

Social login relies on identity federation, where we delegate the identity verification step to a third-party identity provider, who in turn shares with us the information we request (such as email address, full name, and date of birth).

Common implementations of identity federation rely on OpenID Connect, an identity layer on top of the OAuth2 standard.

4.6. Machine to machine, representing a person

There is also a situation where a person authorizes a machine (eg a 3rd party service) to perform actions against our API on their behalf. For example, a mobile application that provides an alternative UI for Twitter.

It is important to highlight how this differs from the first scenario we reviewed (pure machine-to-machine authentication). In this case, the third-party service has no right to perform any operations on our API alone. Third-party services can only perform actions on our API if the user grants them access, limited to their permission set. I can install a mobile app to tweet on my behalf, but I cannot authorize it to tweet on David Guetta's behalf.

"Basic" authentication is very inappropriate here: we don't want to share our passwords with third-party applications. The more people who see our passwords, the more likely they are to be compromised.

Also, maintaining an audit trail with shared credentials is a nightmare. When things go wrong, it's impossible to tell who did what: was it really me? Is it one of the 20 apps I'm sharing credentials with? Who is responsible?

This is a textbook scenario for OAuth 2, where third parties never see our username and password. They receive an opaque access token from the authentication server, which our API knows how to check to grant (or deny) access.

5. What should we do next

Browsers are our main target, it's decided. Our authentication strategy needs to evolve accordingly!

We'll start by converting our "basic" authentication flow to a login form with session-based authentication. We're going to build an admin dashboard from scratch. It will include a login form, a logout link and a form to change the password. It will give us an opportunity to discuss some security challenges (such as XSS), introduce new concepts (such as cookies, HMAC tags) and try new tools (such as flash messages actix-session).

This will be the roadmap for the next episode! goodbye!

5. Footnotes

1  base64-encoding ensures that all characters in the output are ASCII, but it offers no protection: no secret is needed for decoding. In other words, encoding is not encryption!
5  Assuming that the input space is limited (that is, the password length has an upper limit), a perfect hash function f(x) == f(y) implies that x == y can be found theoretically.
2  When researching brute force attacks, you'll often see mentions of rainbow tables, an efficient data structure for precomputing and looking up hashes.
3  This rough calculation should clearly show that even if the server stores passwords using a fast hashing algorithm, using randomly generated passwords gives you as a user a significant level of protection against brute force attacks. Always using a password manager is indeed a boost One of the easiest ways to secure a profile.
4  In general, OWASP is a treasure trove of quality educational material on Web application security. You should become as familiar as possible with the OWASP material, especially if you don't have an application security expert in your team/organization to support you. On top of the cheat sheet we linked, make sure to browse their Application Security Verification Criteria.
6  This is why OWASP recommends adding an additional layer of defense. All hashes stored in the database are encrypted with a shared secret known only to the application. However, encryption also presents its own set of challenges: where will we store the keys? How do we rotate it? The answer usually involves a hardware security module (HSM) or secret vault, such as AWS CloudHSM, AWS KMS, or Hashicorp Vault. A comprehensive overview of key management is beyond the scope of this book.
7 I didn't dig into the source code of the different hashing algorithms implemented by PasswordVerifier , but I do wonder why verify_password needs to take &self as a parameter. Argon2 it's absolutely useless, but it forces us to pass an Argon2::default in order to call verify_password.
8  PasswordVerifier::verify_password also does something that relies on Output to compare two hashes, rather than using raw bytes. The Output implementations PartialEq and Eq are designed to be evaluated in constant time, no matter how different or similar the inputs are, the function execution will take the same amount of time. Assuming the attacker has full knowledge of the hash algorithm configuration the server is using, they can analyze the response time of each authentication attempt to deduce the first byte of the password hash, which, combined with a dictionary, can help them crack the password. The viability of this attack is debatable, even more so when salting is in place. Still, it didn't cost us anything, and it's better to be safe than sorry.
9  Our examples are intentionally oversimplified. In effect, each of these states will in turn have substates that await each state in the body of the function we're calling. The future can be turned into a deeply nested state machine!
10  This heuristic is reported in "Async: What is Blocking?" Contributed by Alice Rhyl, one of tokio's maintainers. I strongly recommend you to read an article to better understand its basic tokio mechanism async/await!
11  In a real life scenario, there is a network between the attacker and your server. Load and network differences may mask speed differences for a limited set of attempts, but if you collect enough data points, you should be able to notice a statistically significant difference in latency.
12  This is why you should never enter your passwords into a website that doesn't use HTTPS, i.e. HTTP + TLS.
13 Implementing a secure login form is its own challenge, hello CSRF! We'll look at it more closely later in this chapter.

Guess you like

Origin blog.csdn.net/zmule/article/details/126549326