[Traducción] Autenticación de contraseña en Rust

Este artículo está traducido del capítulo del blog del autor del libro Zero To Production In Rust Verificación de contraseña en Rust

La traducción automática tiene un sabor fuerte, avíseme si hay algún problema. Este artículo habla sobre los problemas de seguridad encontrados en el sistema de pago, los problemas en profundidad capa por capa y expande gradualmente el conocimiento comercial y de seguridad encontrado, complementado con código y pruebas unitarias.

Aprendí conocimientos de seguridad en él de la siguiente manera

  1. Autenticación de contraseña, autenticación básica
  2. Almacenamiento de contraseñas (el enfoque de este artículo), contraseñas cifradas y sus métodos de ataque y métodos de prevención
  3. Intercambio de red criptográfica, TLS
  4. Flujo de autenticación, OAuth

Utilice principalmente la caja RustCrypto, donde sha3, base64, argon2

El siguiente texto comienza

Este artículo es un ejemplo de Zero to Production in Rust, un libro sobre el desarrollo de back-end en Rust.
Puede obtener una copia del libro en zero2prod.com.
Después de suscribirse al correo electrónico, puede recibir notificaciones de artículos recién publicados a tiempo.

1. Asegurar nuestras API

En el Capítulo 9, agregamos un nuevo punto final a la API, POST /boletines.Toma la pregunta del boletín como entrada y envía un correo electrónico a todos los suscriptores.

Pero tenemos un problema en el que cualquiera puede acceder a la API y transmitir lo que quiera a toda nuestra lista de correo.

Es hora de actualizar nuestras capacidades de seguridad API.
Si bien la autenticación con contraseña es el método de autenticación más fácil, existen algunas dificultades, por lo que comenzaremos desde cero con la autenticación básica, a partir de la cual examinaremos varias clases de ataques contra la API y cómo contrarrestarlos.

Con fines pedagógicos, este capítulo, como el resto del libro, trata de aprender a partir de los errores. ¡Asegúrate de leer hasta el final del artículo si no quieres desarrollar malos hábitos de seguridad!

Capítulo 10, Parte 0

  1. Protege nuestra API
  2. certificado
    1. defecto
      1. cosas que saben
      2. algo que tienen
      3. Qué son
    2. autenticación multifactor
  3. autenticación basada en contraseña
    1. autenticación básica
      1. Extraer credenciales
    2. autenticación de contraseña, el enfoque ingenuo
    3. almacenamiento de contraseña
      1. No es necesario almacenar la contraseña original
      2. usar hash criptográfico
      3. ataque de preimagen
      4. Ataque de diccionario ingenuo
      5. Ataque de diccionario
      6. Aragón2
      7. Sal
      8. Formato de cadena PHC
    4. No bloquee los ejecutores asíncronos
      1. El contexto de seguimiento es local del subproceso
    5. enumeración de usuarios
  4. ¿es seguro?
    1. Seguridad de la capa de transporte (TLS)
    2. restablecer la contraseña
    3. tipo de interacción
    4. máquina a máquina
      1. Credenciales de cliente a través de OAuth2
    5. personas a través del navegador
      1. identidad conjunta
    6. máquina a máquina, en nombre de una persona
  5. ¿Qué debemos hacer a continuación?

2. Autenticación

Necesitamos una forma de comprobar quién llamó a POST/boletines. Solo unas pocas personas (los responsables del contenido) pueden enviar correo a toda la lista de correo.

Primero averigüe la identidad de la persona que llama y luego autentíquela . ¿Cómo hacer?

Pregúntele a la persona que llama por su propia información única. Existen múltiples enfoques para esto, todos dentro de 3 categorías:

  1. algo que saben (por ejemplo, contraseña, PIN, pregunta de seguridad);
  2. algo de su propiedad (por ejemplo, un teléfono inteligente, usando una aplicación de autenticación);
  3. Son algo (por ejemplo, huellas dactilares, Face ID de Apple).

Cada método tiene sus debilidades.

2.1 Desventajas

2.1.1 Lo que saben

La contraseña debe ser lo suficientemente larga, las cortas son vulnerables a los ataques de fuerza bruta.
Las contraseñas deben ser únicas, y la información disponible públicamente (como fechas de nacimiento, nombres de familiares, etc.) no debe dar a los atacantes ninguna posibilidad de "adivinar" la contraseña.
Las contraseñas no deben reutilizarse y, si alguna de ellas se ve comprometida, corre el riesgo de otorgar acceso a otros servicios que comparten la misma contraseña.

En promedio, una persona tiene 100 o más cuentas en línea y no se les puede exigir que recuerden cientos de largas contraseñas únicas. Los administradores de contraseñas ayudan, pero aún no son convencionales y la experiencia del usuario suele ser subóptima.

2.1.2 Lo que tienen

Los teléfonos inteligentes y las claves U2F se pueden perder, lo que impide que los usuarios accedan a sus cuentas. También se pueden robar o filtrar, dando a los atacantes la oportunidad de hacerse pasar por una víctima.

2.1.3 Son algo

La biometría, a diferencia de las contraseñas, no se puede cambiar, no se puede "rotar" la huella dactilar ni cambiar el patrón de los vasos sanguíneos de la retina. Resulta que falsificar huellas dactilares es más fácil de lo que la mayoría de la gente piensa, y es información a la que las agencias gubernamentales a menudo tienen acceso, a la que pueden hacer mal uso o perder.

2.2 Autenticación multifactor

Dado que cada método tiene sus propios defectos, ¿qué debemos hacer? ¡Pues podemos combinarlos!

Esto es prácticamente autenticación multifactor (MFA), que requiere que los usuarios proporcionen al menos dos tipos diferentes de factores de autenticación para obtener acceso.

3. Autenticación basada en contraseña

Pasemos de la teoría a la práctica: ¿cómo logramos la certificación?

La contraseña parece el más fácil de los tres métodos que mencionamos. ¿Cómo debemos pasar el nombre de usuario y la contraseña a nuestra API?

3.1 Autenticación básica

Podemos utilizar el esquema de autenticación "Básico", que es un estándar definido por el Grupo de trabajo de ingeniería de Internet (IETF) en RFC 2617 y actualizado posteriormente por RFC 7617.

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

La API debe buscar el encabezado de Autorización en las solicitudes entrantes, que tiene la siguiente estructura:

Authorization: Basic <encoded credentials>

¿Dónde está la codificación base64 de {nombre de usuario}: {contraseña} 1

De acuerdo con la especificación, debemos dividir la API en espacios o dominios de protección, y los recursos dentro del mismo dominio se protegen mediante el mismo esquema de autenticación y conjunto de credenciales. Solo necesitamos proteger un POST/boletines de punto final. Por lo tanto, tendremos un reino llamado publicar.

La API debe rechazar todas las solicitudes con encabezados faltantes o con credenciales no válidas, la respuesta debe usar un código de estado 401 No autorizado y contener el encabezado especial WWW-Authenticate, que contiene el desafío. Un desafío es una cadena que explica a la persona que llama a la API qué tipo de esquema de autenticación nos gustaría ver en el ámbito relevante. En nuestro caso, con autenticación básica, debería ser:

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

¡Hagamos que suceda!

3.1.1 Extraer Credenciales

Extraer el nombre de usuario y la contraseña de la solicitud entrante será nuestro primer hito.
Comencemos con un caso desagradable, una solicitud entrante rechazada sin encabezado de Autorización.

//! 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"]);
}

Falla en la primera afirmación:

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

Tuvimos que actualizar el programa para cumplir con los nuevos requisitos. Podemos usar el extractor HttpRequest para acceder a los encabezados asociados con la solicitud entrante:

//! 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!()
}

Para extraer las credenciales, debemos lidiar con la codificación base64. Agreguemos la caja base64 como una dependencia:

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

Ahora podemos escribir 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)
    })
}

Tómese un momento para recorrer el código línea por línea y comprender completamente lo que está sucediendo. ¡Muchas operaciones que pueden salir mal!
¡Será útil abrir el RFC y comparar los contenidos de este libro!

Aún no hemos terminado, nuestras pruebas aún fallan.
Necesitamos actuar sobre el error devuelto por 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)?;
    // [...]
}

Nuestra aserción de código de estado está feliz de pasar, pero todavía falta un título para completar la segunda aserción:

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

Hasta ahora, ha sido suficiente especificar qué código de estado devolver para cada error. Ahora necesitamos algo más, un título. Necesitamos mover la preocupación ResponseError::status_code de 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.
}

¡Nuestra prueba de certificación pasó! Otra parte del código reportó un error nuevamente:

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 ahora rechaza todas las solicitudes no autenticadas, incluidas las que hicimos en nuestras pruebas de caja negra. Podemos detener el sangrado proporcionando una combinación aleatoria de nombre de usuario y contraseña:

//! 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.")
    }
    
    // [...]
}

El conjunto de pruebas se vuelve verde de nuevo.

3.2 Autenticación de contraseña, el enfoque ingenuo

Una capa de autenticación que acepta credenciales aleatorias no es ideal.
Necesitamos comenzar a validar las credenciales que extraemos del encabezado de Autorización, deben compararse con una lista de usuarios conocidos.

Crearemos una nueva tabla de usersPostgres para almacenar esta lista:

sqlx migrate add create_users_table

Un primer borrador de la arquitectura podría verse así:

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

Luego podemos actualizar nuestro controlador para consultarlo cada vez que se realiza la autenticación:

//! 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?;
    // [...]
}

Es una buena idea registrar quién está llamando a POST/boletines, así que agreguemos un rastreo alrededor del controlador:

//! 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));
    // [...]
}

Ahora necesitamos actualizar nuestras pruebas de ruta feliz para especificar una validación_credenciales Generaremos un usuario de prueba para cada instancia de nuestra aplicación de prueba. Todavía no hemos implementado un flujo de registro para los editores de boletines, por lo que no podemos adoptar un enfoque completamente de caja negra, estamos inyectando detalles de usuario de prueba directamente en la base de datos por ahora:

//! 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 proporcionará un método auxiliar para recuperar su nombre de usuario y contraseña

//! 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)
    }
}

Luego lo llamaremos desde nuestro método post_newsletters en lugar de usar credenciales aleatorias:

//! 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.")
    }
}

Todas nuestras pruebas ahora pasan.

3.3 Almacenamiento de contraseñas

Almacenar contraseñas de usuario sin procesar en una base de datos no es una buena idea.

Un atacante con acceso a sus datos almacenados puede comenzar inmediatamente a hacerse pasar por sus usuarios, con nombres de usuario y contraseñas listos.
Ni siquiera tienen que comprometer su base de datos en vivo, bastará con una copia de seguridad sin cifrar.

3.3.1 No es necesario almacenar la contraseña original

¿Por qué almacenamos contraseñas en primer lugar?
Necesitamos realizar una verificación de igualdad, y cada vez que un usuario intente autenticarse, verificamos que la contraseña que proporcionó coincida con lo que esperamos.

Si lo que nos importa es la igualdad, podemos empezar a diseñar una estrategia más compleja.
Por ejemplo, podemos transformar contraseñas aplicando una función antes de compararlas.

Dada la misma entrada, todas las funciones deterministas devuelven la misma salida.
Dejemos que nuestra función determinista f: psw_candidate == esperado_psw implique que f(psw_candidate) == f(esperado_psw).
Pero eso no es suficiente, ¿qué pasaría si para cada posible cadena de entrada f devolviera? hola La autenticación de contraseña tendrá éxito sin importar qué entrada se proporcione.

Necesitamos ir en la dirección opuesta: si f(psw_candidate) == f(expected_psw) then psw_candidate == added_psw. Esto es posible dado que nuestra función f tiene una propiedad adicional: debe ser inyectiva, si x != yentonces f(x) != f(y) .

Si tuviéramos tal función f, podríamos evitar almacenar la contraseña original por completo: cuando un usuario se registra, calculamos f (contraseña) y la almacenamos en nuestra base de datos. la contraseña se descarta. Cuando el mismo usuario intenta iniciar sesión, calculamos f(psw_candidate) y verificamos si coincide con el valor de f(contraseña) que almacenamos durante el registro. La contraseña original nunca se conserva.

¿Esto realmente mejora nuestra postura de seguridad? Depende de f!

No es difícil definir una función inyectiva, la función inversa f("hola") = "olleh" satisface nuestros criterios. También es fácil adivinar cómo revertir la transformación para recuperar la contraseña original, no se interpone en el camino de un atacante. Podemos hacer que la transformación sea más compleja, lo suficientemente compleja como para dificultar que un atacante encuentre la transformación inversa. Incluso eso podría no ser suficiente. A menudo, es suficiente que un atacante pueda recuperar ciertas propiedades de la entrada (como la longitud) de la salida para implementar, por ejemplo, un ataque de fuerza bruta dirigido. Necesitamos algo más robusto, donde no debería haber una relación entre cuán similares son dos entradas y cuán similares son las salidas correspondientes. x e y no se conocen como f(x) y f(y).

Queremos una función hash criptográfica.
Una función hash asigna una cadena desde un espacio de entrada a una salida de longitud fija.
El adjetivo criptografía se refiere a la propiedad de consistencia que acabamos de discutir, también conocida como efecto avalancha: pequeñas diferencias en las entradas dan como resultado salidas tan diferentes que parecen no estar correlacionadas.

Hay una advertencia: la función hash no es inyectiva 2, el riesgo de colisión es pequeño, si f(x) == f(y) hay una alta probabilidad (¡no del 100%!) de que x == y.

3.3.2 Uso de hash criptográfico

Suficiente teoría, actualicemos nuestra implementación para cifrar las contraseñas antes de almacenarlas.

Hay varias funciones hash criptográficas, MD5, SHA-1, SHA-2, SHA-3, KangarooTwelve, etc. No vamos a profundizar en los pros y los contras de cada algoritmo, cuando se trata de cifrados, la razón quedará clara en unas pocas páginas. Por el bien de esta sección, pasemos a SHA-3, el miembro más nuevo de la familia de algoritmos hash seguros.

Además del algoritmo, también debemos elegir el tamaño de salida, por ejemplo, SHA3-224 usa el algoritmo SHA-3 para producir una salida de tamaño fijo de 224 bits. Las opciones son 224, 256, 384 y 512. Cuanto más larga sea la salida, menos probable es que encontremos una colisión. Por otro lado, necesitaremos más almacenamiento y consumiremos más ancho de banda al usar hashes más largos. SHA3-256 debería ser más que suficiente para nuestro caso de uso.

La organización Rust Crypto proporciona una implementación de SHA-3, crate sha3. Agreguemos esto a nuestras dependencias:

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

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

Para mayor claridad, cambiemos el nombre de la columna de contraseña a password_hash :

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

Nuestro proyecto debería dejar de compilar:

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! encontró que una de nuestras consultas está usando una columna que ya no existe en el esquema actual. La validación en tiempo de compilación de las consultas SQL es bastante ordenada, ¿no es así?

Nuestra función validate_credentials se ve así:

//! 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()
    )
    // [...]
}

Actualicémoslo para usar contraseñas hash:

//! 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
    )
    // [...]
}

Desafortunadamente, no se compila de inmediato:

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 devuelve una matriz de bytes de longitud fija, y nuestra columna password_hash es de tipo cadena de TEXTO.
Podemos cambiar el esquema de la tabla de usuarios para almacenar password_hash como binario. Alternativamente, podemos codificar los bytes devueltos por Digest::digest en una cadena usando formato hexadecimal.

Evitemos otra migración usando la segunda opción:

//! [...]

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);
    // [...]
}

El código de la aplicación ahora debería compilarse. En cambio, el conjunto de pruebas requiere más trabajo. El método auxiliar es consultar la tabla a través de test_user para recuperar un conjunto válido de usuarios con credenciales, ahora que almacenamos hashes en lugar de contraseñas sin procesar, ¡esto ya no es posible!

//! 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.");
}

Necesitamos TestApp para almacenar la contraseña generada aleatoriamente para que podamos acceder a ella en el método auxiliar. Comencemos por crear una nueva estructura auxiliar, 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.");
    }
}

Luego podemos adjuntar una instancia de TestUserto, TestApp, como un nuevo campo:

//! 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
}

Finalmente, eliminemos add_test_user y actualicemos TestApp::post_newsletters para 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))
            // [...]
    }
}

El conjunto de pruebas ahora debería compilarse y ejecutarse correctamente.

3.3.3 Ataque de preimagen

Si un atacante se apodera de nuestra tabla, ¿es suficiente SHA3-256 para proteger la contraseña de nuestros usuarios?

Supongamos que el ataque quiere descifrar un hash de contraseña específico en nuestra base de datos. El atacante ni siquiera necesita recuperar la contraseña original. Para autenticarse con éxito, solo necesitan encontrar una cadena de entrada cuyo hash sSHA3-256 coincida con la contraseña que están tratando de descifrar, en otras palabras, una colisión.
Esto se llama un ataque de preimagen.

¿Qué tan difícil es?

Las matemáticas son un poco complicadas, pero un ataque de fuerza bruta tiene una complejidad de tiempo exponencial de 2^n, donde n es la longitud del hash en bits. Si n > 128, el cálculo se considera inviable. A menos que se encuentre una vulnerabilidad en SHA-3, no debemos preocuparnos por los ataques de preimagen contra SHA3-256.

3.3.4 Ataque de diccionario ingenuo

Sin embargo, no vamos a codificar entradas arbitrarias, podemos reducir el espacio de búsqueda haciendo algunas suposiciones sobre la contraseña original: ¿cuánto tiempo tiene? ¿Qué símbolos se utilizan? Digamos que estamos buscando una contraseña alfanumérica con menos de 17 caracteres3. 2

Podemos contar el número de contraseñas candidatas:

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

Resume aproximadamente 8*10^24 posibilidades. No pude encontrar datos específicos sobre SHA3-256, pero los investigadores lograron calcular alrededor de 900 millones de hashes SHA3-512 por segundo usando una unidad de procesamiento de gráficos (GPU).

Suponiendo una tasa de hash de ~10^9 por segundo, necesitamos ~10^15 segundos para codificar todas las contraseñas candidatas. La edad aproximada del universo es 4 * 10^17 segundos.
Incluso si paralelizamos nuestra búsqueda usando 1 millón de GPU, todavía toma ~10^9 segundos, unos 30 años. 3

3.3.5 Ataque de diccionario

Volviendo a lo que comentamos al comienzo de este capítulo, es imposible que una sola persona recuerde cientos de contraseñas únicas para servicios en línea.
Se basan en administradores de contraseñas o reutilizan una o más contraseñas en varias cuentas.

Además, incluso con el uso repetido, la mayoría de las contraseñas están lejos de ser aleatorias, palabras comunes, nombres completos, fechas, nombres de equipos deportivos populares, etc. Los atacantes pueden diseñar fácilmente un algoritmo simple para generar miles de contraseñas plausibles y pueden intentar atacar encontrando las contraseñas más comunes de los conjuntos de datos de contraseñas de numerosas violaciones de seguridad en la última década.

Pueden precalcular los hash SHA3-256 de los 10 millones de contraseñas principales en minutos. Luego comienzan a escanear nuestra base de datos en busca de coincidencias.

Esto se llama ataque de diccionario y es muy efectivo.

Todas las funciones hash criptográficas que hemos mencionado hasta ahora están diseñadas para ser rápidas. Lo suficientemente rápido como para que cualquiera pueda realizar un ataque de diccionario sin usar hardware especializado.

Necesitamos algo que sea mucho más lento, pero que tenga el mismo conjunto de propiedades matemáticas que una función hash criptográfica.

3.3.6. Aragón2

El Proyecto de seguridad de aplicaciones web abiertas (OWASP)  4 tiene una guía útil sobre el almacenamiento seguro de contraseñas, con una sección completa sobre cómo elegir el algoritmo hash correcto:

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

Usando Argon2id, la configuración mínima es 15MiB de memoria, el número de iteraciones es 2 y el grado de paralelismo es 1.
Si Argon2id no está disponible, utilice bcrypt con un factor de trabajo de 10 o superior y un límite de contraseña de 72 bytes.
Para sistemas más antiguos que usan scrypt, use el parámetro de costo mínimo de CPU/memoria (2^16), el tamaño mínimo de bloque 8 (1024 bytes) y el parámetro de paralelización 1.
Si se requiere el cumplimiento de FIPS-140, use PBKDF2 con un factor de trabajo de 310 000 o superior, configurado con la función hash interna de HMAC-SHA-256.
Considere usar chili para brindar una defensa adicional en profundidad (aunque se usa solo, no brinda funciones de seguridad adicionales).

Todas estas opciones, Argon2, bcrypt, scrypt, PBKDF2, están diseñadas para ser computacionalmente exigentes.
También exponen los parámetros de configuración (como el factor de trabajo de bcrypt) para ralentizar aún más los cálculos de hash: los desarrolladores de aplicaciones pueden ajustar algunas perillas para mantenerse al día con la aceleración del hardware, sin migrar a algoritmos más nuevos cada pocos años.

Como sugiere OWASP, reemplacemos SHA-3 con Argon2id.
La organización Rust Crypto ha proporcionado nuevamente una implementación Rust pura de argon2. 5

Agreguemos esto a nuestras dependencias:

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

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

Para codificar contraseñas, necesitamos crear una instancia de estructura Argon2. La firma del método se ve así

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

El algoritmo es una enumeración: nos permite elegir qué variante de Argon2 usar, Argon2d, Argon2i, Argon2id. Para cumplir con las recomendaciones de OWASP, elegiremos Algorith::Argon2id.

Version logra un propósito similar, y elegiremos la más reciente, Version::V0x13 .

Params,Params::new especifica todos los parámetros obligatorios que necesitamos proporcionar para construir uno.

//! 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 y p_cost se asignan a los requisitos de OWASP:

  • m_cost es el tamaño de la memoria, expresado en kilobytes
  • t_cost es el número de iteraciones;
  • p_cost es el grado de paralelismo.
  • output_len, por el contrario, determina la longitud del hash devuelto. Si se omite, el valor predeterminado será de 32 bytes. Esto equivale a 256 bits, la misma longitud que el hash que obtenemos con SHA3-256.

En este punto, sabemos lo suficiente para construir uno:

//! 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 implementa el rasgo PasswordHasher:

//! 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 es la interfaz unificada reexportada de la caja para manejar hashes de contraseña compatibles con varios algoritmos (actualmente Argon2, PBKDF2 y scrypt).

PasswordHasher::hash_password es un poco diferente de Sha3_256::digest, que requiere un parámetro salt adicional además de la contraseña original.

3.3.7 Sal

Argon2 es mucho más lento que SHA-3, pero no lo suficiente como para que los ataques de diccionario sean inviables. Llevará más tiempo codificar las 10 millones de contraseñas más comunes, pero no mucho.

Pero, ¿qué sucede si un atacante tiene que repetir todo el diccionario para cada usuario de nuestra base de datos? ¡Se vuelve más desafiante!

Esto es lo que hace agregar sal. Para cada usuario, generamos una cadena aleatoria única, la sal. El salt se antepone a la contraseña del usuario antes de que se genere el hash. PasswordHasher::hash_password maneja la transacción previa por nosotros.

La sal se almacena junto al hash de la contraseña en nuestra base de datos. Si un atacante obtiene una copia de seguridad de la base de datos, tendrá acceso a todas las sales. 6  pero tienen que calcular dictionary_size * n_users hash en lugar de dictionary_size. Además, la precomputación de hashes ya no es una opción, lo que nos da tiempo para detectar infracciones y tomar medidas (por ejemplo, forzar el restablecimiento de contraseñas para todos los usuarios).

Agreguemos una columna password_salt a la tabla de usuarios:

//! 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)
    }
}

Desafortunadamente, falla al compilar:

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`

La salida proporciona otros métodos para obtener representaciones de cadenas. Por ejemplo, salida ::b64_encode. Siempre que estemos felices de cambiar la codificación supuesta del hash almacenado en la base de datos, funcionará.

Dados los cambios necesarios, podemos buscar algo mejor que la codificación base64.

3.3.8. Formato de cadena PHC

Para autenticar a los usuarios, necesitamos repetibilidad: tenemos que ejecutar la misma rutina hash cada vez. Salts y contraseñas son solo un subconjunto de las entradas de Argon2id. Todos los demás parámetros de carga útil (t_cost, m_cost, p_cost) son igualmente importantes para obtener el mismo valor hash dado el mismo par de sal y contraseña.

Si almacenamos la representación codificada en base64 del hash, hacemos una fuerte suposición implícita: todos los valores almacenados en la columna password_hash se calcularon utilizando los mismos parámetros de carga.

Como discutimos en las secciones anteriores, las capacidades del hardware evolucionan con el tiempo: los desarrolladores de aplicaciones deben mantenerse al día aumentando el costo computacional del hashing con parámetros de carga más altos. ¿Qué sucede cuando tiene que migrar sus contraseñas almacenadas a una configuración hash más nueva?

Para continuar autenticando a los usuarios antiguos, debemos almacenar junto a cada hash el conjunto exacto de parámetros de carga útil utilizados para calcularlo. Esto permite una migración fluida entre dos configuraciones de carga diferentes: cuando el antiguo usuario se autentica, verificamos la validez de la contraseña usando los parámetros de carga almacenados; luego volvemos a calcular los hash de la contraseña usando los nuevos parámetros de carga y actualizamos la información almacenada.

Podemos tomar el camino fácil y agregar tres nuevas columnas a nuestra tabla de usuarios: t_cost, m_cost y p_cost. Mientras el algoritmo siga siendo Argon2id, funcionará.

¿Qué pasaría si se encuentra un error en Argon2id y nos vemos obligados a migrar lejos de él? Es posible que deseemos agregar una columna de algoritmo, así como una nueva columna para almacenar el parámetro de carga que reemplaza Argon2id.

Se puede hacer, pero tedioso. Afortunadamente, hay una mejor solución: el formato de cadena PHC. El formato de cadena PHC proporciona una representación estándar para hashes criptográficos: incluye el propio hash, la sal, el algoritmo y todos sus parámetros asociados.

Usando el formato de cadena PHC, el hash de la contraseña de Argon2id se ve así:

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

La caja argon2 expone PasswordHash, una implementación de Rust en formato PHC:

//! 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>,
}

Almacenar hashes de contraseña en formato de cadena PHC nos evita que Argon2 inicialice la estructura con un parámetro explícito7. Podemos confiar en la implementación del rasgo Argon2: PasswordVerifier  7

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

Al pasar el hash PasswordHash esperado, Argon2 puede deducir automáticamente qué parámetro de carga útil y sal se deben usar para verificar que la contraseña candidata coincida8

Actualicemos nuestra implementación:

//! 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)
}

Se compila con éxito. También puede notar que ya no tratamos con la sal directamente, el formato de cadena PHC lo maneja implícitamente. Podemos deshacernos de la columna de sal por completo:

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

¿Qué pasa con nuestra prueba? Dos de ellos fallan:

---,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`,

Podemos mirar los registros para averiguar cuál es el problema:

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

Veamos el código de generación de contraseña de nuestro usuario de prueba:

//! 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);
        // [...]
    }
}

¡Todavía estamos usando SHA-3! Vamos a actualizarlo:

//! 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();
        // [...]
    }
}

El conjunto de pruebas ahora debería pasar. Hemos eliminado toda mención de sha3 de nuestro proyecto, ahora podemos eliminarlo de la lista de dependencias en Cargo.toml.

3.4 No bloquear ejecutores asíncronos

¿Cuánto tiempo se tarda en verificar las credenciales de usuario cuando se ejecutan nuestras pruebas de integración? Actualmente no tenemos un rastro de los hash de contraseñas, arreglemos eso:

//! 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)
}

Ahora podemos ver los registros de una de las pruebas de integración:

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

Unos 10 milisegundos. Esto puede causar problemas de carga, el notorio problema de bloqueo.

async/await en Rust se basa en un concepto llamado despacho cooperativo.

¿Como funciona? Veamos un ejemplo:

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

my_fn devuelve un futuro. Cuando esperamos el futuro, nuestro tiempo de ejecución asíncrono (Tokio) entra en escena: comienza a sondearlo.

¿Cómo implementa la encuesta el futuro devuelto por my_fn? Puedes pensar en ello como una máquina de estado:

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

Cada vez que se llama a la encuesta, intenta avanzar alcanzando el siguiente estado. Por ejemplo, si a.await() regresa, comenzamos a esperar en b()  9 .

Para cada .await en el cuerpo de la función asíncrona, tenemos un estado diferente en MyFnFuture. Es por eso que las llamadas .await a menudo se denominan puntos de rendimiento, donde nuestro futuro avanza desde el .await anterior al siguiente antes de devolver el control al ejecutor.

Luego, el ejecutor puede elegir sondear el mismo futuro nuevamente o priorizar el progreso en otra tarea. Así es como los tiempos de ejecución asincrónicos como tokio logran progresar en múltiples tareas simultáneamente, estacionando y reanudando constantemente cada tarea. En cierto modo, puede pensar en el tiempo de ejecución asíncrono como un gran malabarista.

La suposición básica es que la mayoría de las tareas asincrónicas realizan algún tipo de trabajo de entrada-salida (IO), y la mayor parte de su tiempo de ejecución se dedicará a esperar que sucedan otras cosas (por ejemplo, el sistema operativo nos notifica que los datos están disponibles para leer en un socket), por lo que podemos ejecutar de manera eficiente muchas más tareas simultáneamente de lo que podemos lograr asignando a cada tarea una unidad de ejecución paralela (por ejemplo, un subproceso por núcleo del sistema operativo).

Asumiendo que las tareas cooperan devolviendo con frecuencia el control a los ejecutores, este modelo funciona muy bien. En otras palabras, se espera que la encuesta sea rápida, debe devolver 10 en menos de 10-100 microsegundos . Si llamar a la encuesta lleva más tiempo (o peor aún, nunca regresa), entonces el ejecutor asíncrono no puede avanzar en ninguna otra tarea, que es lo que la gente quiere decir cuando dice que "la tarea está bloqueando el hilo ejecutor/asincrónico".

Siempre debe tener en cuenta las cargas de trabajo intensivas de la CPU que pueden tardar más de 1 ms, el hash de contraseña es un buen ejemplo. Para usar tokio mejor, debemos usar tokio::task::spawn_blocking.Estos subprocesos están reservados para bloquear operaciones y no interferirán con la programación de tareas asincrónicas.

¡Pongámonos a trabajar!

//! 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)?;
    // [...]
}

Quejas de cheques prestados:

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

Estamos iniciando un cálculo en un subproceso separado, y el subproceso en sí puede sobrevivir a la tarea asíncrona de la que lo generamos. Para evitar este problema, spawn_blocking requiere que sus argumentos tengan un tiempo de vida estático, lo que nos impide pasar una referencia al contexto de la función actual al cierre.

Podría argumentar, "estamos usando move || {} , el cierre debe tener el hash_contraseña_esperado". ¡tienes razón! Pero eso no es suficiente. Echemos un vistazo a cómo se define PasswordHash:

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

Contiene una referencia a la cadena que analiza. Necesitamos mover la propiedad de la cadena original a nuestro cierre y también mover la lógica de análisis.

Para mayor claridad, creemos una función separada, verificar_contraseña_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)
}

compila bien!

3.4.1 El contexto de seguimiento es local de subprocesos

Revisemos nuevamente el registro de hashspan de verificación de contraseña:

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="...")

Nos faltan todos los atributos heredados del seguimiento raíz de la solicitud correspondiente, como request_id, http.method, http.route, etc. ¿Por qué?

Veamos la documentación de rastreo:

Los tramos forman una estructura de árbol y, a menos que sea el tramo raíz, todos los tramos tienen un padre y posiblemente uno o más hijos. Cuando se crea un nuevo seguimiento, el seguimiento actual se convierte en el padre del nuevo seguimiento.

La traza actual es la que devuelve tracing::Span::current() , revisemos su documentación:

Devuelve un identificador para el seguimiento que se considera que el recopilador es el seguimiento actual.

Si el recopilador indica que actualmente no está rastreando un seguimiento, o si el subproceso que llama a esta función no está actualmente dentro de un seguimiento, el seguimiento devuelto se desactivará.

"Rastreo actual" en realidad significa "rastreo activo para el subproceso actual". Es por eso que no heredamos ninguna propiedad: generamos nuestro cálculo en un subproceso separado, y tracing::info_span! no encontró ninguna actividad asociada con él mientras se estaba ejecutando. Durar

Podemos arreglar esto adjuntando explícitamente el seguimiento actual al hilo recién generado:

//! 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(/* */)
        })
    })
    // [...]
}

Puede verificar que funciona, ahora estamos obteniendo todas las propiedades que nos interesan. Aunque un poco detallado, escribamos una función auxiliar:

//! 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(/* */)
    })
    // [...]
}

Ahora podemos usarlo fácilmente siempre que necesitemos descargar algunos cálculos intensivos de la CPU a un grupo de subprocesos dedicado.

3.5 Enumeración de usuarios

Agreguemos un nuevo caso de prueba:

//! 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"]
    );
}

La prueba debe pasar inmediatamente. ¿Pero cuánto tiempo?

¡Veamos los registros!

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, ...)

Aproximadamente 1 ms.

Agreguemos otra prueba: esta vez pasamos un nombre de usuario válido y una contraseña incorrecta.

//! 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"]
    );
}

Esto también debería pasar. ¿Cuánto tiempo tarda en fallar la solicitud?

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, ...)

Aproximadamente 10 milisegundos, ¡es un orden de magnitud más pequeño! Podemos aprovechar esta diferencia para realizar ataques de sincronización, que son miembros de la clase más amplia de ataques de canal lateral.

Si un atacante conoce al menos un nombre de usuario válido, puede comprobar el tiempo de respuesta del servidor 11 para ver si existe otro nombre de usuario. Estamos investigando una posible vulnerabilidad de enumeración de usuarios. ¿Es esto un problema?

Depende, si está ejecutando Gmail, hay muchas otras formas de determinar si existe una dirección de correo electrónico @gmail.com. ¡La validez de las direcciones de correo electrónico no es un secreto!

Si ejecuta un producto SaaS, la situación puede ser más sutil. Supongamos un escenario hipotético: su producto SaaS ofrece un servicio de nómina y utiliza direcciones de correo electrónico como nombres de usuario. Hay páginas de inicio de sesión separadas para el personal y los administradores. Mi objetivo es acceder a los datos de nómina y necesito comprometer a un empleado con acceso privilegiado. Podemos rastrear LinkedIn para obtener los nombres y apellidos de todos los empleados del departamento de finanzas. Los correos corporativos siguen una estructura predecible ( [email protected] ), por lo que disponemos de una lista de candidatos. Ahora podemos realizar un ataque de sincronización en la página de inicio de sesión del administrador para reducir la lista a aquellos que tienen acceso.

Incluso en nuestro ejemplo ficticio, la enumeración de usuarios por sí sola no es suficiente para elevar nuestros privilegios. Pero puede servir como trampolín para reducir un conjunto de objetivos para un ataque más preciso.

¿Cómo podemos prevenirlo? Dos estrategias:

  • Elimine la diferencia de tiempo entre las fallas de autenticación causadas por contraseñas no válidas y las fallas de autenticación causadas por nombres de usuario inexistentes;
  • Limite el número de intentos de autenticación fallidos para una IP/nombre de usuario determinado. El segundo suele ser valioso como protección contra ataques de fuerza bruta, pero debe mantenerse en algún estado, que dejaremos para más adelante.

Centrémonos en el primero. Para eliminar la diferencia de tiempo, necesitamos realizar la misma cantidad de trabajo en ambos casos.

Ahora, seguimos esta receta:

Obtenga las credenciales almacenadas para el nombre de usuario dado; devuelva un 401 si no existen; si existen, haga un hash de la contraseña del candidato y compárelo con el hash almacenado. Necesitamos eliminar esa salida anticipada y deberíamos tener una contraseña esperada alternativa (con parámetros salt y load) que se pueda comparar con el hash del candidato de contraseña.

//! 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();
        // [...]
    }
    // [...]
}

Ahora no debería haber diferencias de tiempo estadísticamente significativas.

4. ¿Es seguro?

Hemos hecho todo lo posible para seguir todas las prácticas recomendadas más comunes al crear flujos de autenticación basados ​​en contraseña. Es hora de preguntarse: ¿es seguro?

4.1 Seguridad de la capa de transporte (TLS)

Hemos hecho todo lo posible para seguir todas las prácticas recomendadas más comunes al crear flujos de autenticación basados ​​en contraseña. Es hora de preguntarse: ¿es seguro?

Pasamos las credenciales entre el cliente y el servidor utilizando el esquema de autenticación "básico", el nombre de usuario y la contraseña están codificados, pero no encriptados. Debemos utilizar Transport Layer Security (TLS) para garantizar que nadie pueda espiar el tráfico entre el cliente y el servidor para comprometer las credenciales de los usuarios (ataque de intermediario, MITM) 12 . Nuestra API ya se sirve a través de HTTPS, por lo que no hay nada que hacer aquí.

4.2 Restablecer contraseña

¿Qué sucede si un atacante logra robar un conjunto válido de credenciales de usuario? Las contraseñas no caducan, son secretos de larga duración.

Actualmente, los usuarios no pueden restablecer sus contraseñas. Este es definitivamente un vacío que debemos llenar.

4.3 Tipo de interacción

Hasta ahora, hemos sido bastante vagos acerca de quién llama a nuestra API.

Cuando se trata de autenticación, el tipo de interacción que necesitamos respaldar es un factor de decisión clave.

Veremos tres tipos de personas que llaman:

Otra API (máquina a máquina); una persona, a través del navegador; otra API, en nombre de una persona.

4.4 Máquina a máquina

Un consumidor de su API podría ser una máquina (por ejemplo, otra API). Este suele ser el caso en las arquitecturas de microservicios, donde su funcionalidad proviene de varios servicios que interactúan en la red.

Para mejorar significativamente nuestro perfil de seguridad, tendríamos que incorporar algo que ellos tengan (como la firma de solicitudes) o algo que tengan (como restricciones de rango de IP). Cuando todos los servicios son propiedad de la misma organización, una opción popular es TLS mutuo (mTLS).

Tanto la firma como mTLS se basan en la criptografía de clave pública, y las claves deben aprovisionarse, rotarse y administrarse. La sobrecarga solo se justifica cuando el sistema alcanza un cierto tamaño.

4.4.1 Credenciales del cliente a través de OAuth2

Otra opción es utilizar el flujo de credenciales de cliente de OAuth2. Hablaremos más sobre OAuth2 más adelante, pero hablemos de sus ventajas y desventajas tácticas.

Las API ya no necesitan administrar contraseñas (secretos de cliente, en términos de OAuth2), este problema se delega a un servidor de autorización centralizado. Hay varias implementaciones llave en mano de servidores de autorización, OSS y comerciales. Puede confiar en ellos en lugar de rodar los suyos.

La persona que llama se autentica en el servidor de autorización y, si tiene éxito, el servidor de autenticación le otorga un conjunto de credenciales temporales (tokens de acceso JWT) que se pueden usar para llamar a nuestra API. Nuestra API puede usar criptografía de clave pública para verificar la validez de los tokens de acceso sin retener ningún estado. Nuestra API nunca ve el secreto real, el secreto del cliente.

La validación de JWT no está exenta de riesgos, y la especificación está llena de casos extremos peligrosos. Hablaremos más sobre esto más adelante.

4.5 Humano a través del navegador

¿Qué pasaría si interactuáramos con personas usando un navegador web?

La autenticación "básica" requiere que los clientes proporcionen sus credenciales con cada solicitud. Tenemos un punto final protegido en este momento, pero fácilmente podría imaginarse una situación de cinco o diez páginas que brinde una funcionalidad privilegiada. Tal como está, la autenticación "básica" obligará a los usuarios a enviar sus credenciales en cada página. No muy bueno.

Necesitamos una forma de recordar que el usuario se autenticó hace unos momentos, es decir, adjuntar algún estado a una serie de solicitudes del mismo navegador. Esto se hace usando sesiones.

Se le pide al usuario que se autentique una vez a través del formulario de inicio de sesión 13 : si tiene éxito, el servidor genera un secreto único, un token de sesión autenticado. El token se almacena en el navegador como una cookie segura. Las sesiones, a diferencia de las contraseñas, están diseñadas para caducar, lo que reduce la posibilidad de que los tokens de sesión válidos se vean comprometidos (especialmente si los usuarios inactivos cierran sesión automáticamente). También evita que los usuarios tengan que restablecer su contraseña si sospechan que su sesión ha sido secuestrada, lo que hace que un cierre de sesión forzado sea más aceptable que un restablecimiento automático de contraseña.

Este enfoque a menudo se denomina autenticación basada en sesiones.

4.5.1 Identidad federada

Con la autenticación basada en sesiones, todavía necesitamos manejar un paso de autenticación, el formulario de inicio de sesión. Podemos seguir implementando las nuestras, y todo lo que hemos aprendido sobre las contraseñas seguirá siendo relevante, incluso si eliminamos el esquema de autenticación "básico".

Muchos sitios web optan por ofrecer a sus usuarios una opción adicional: iniciar sesión a través de perfiles sociales, como "Iniciar sesión con Google". Esto elimina la fricción del proceso de registro (¡no es necesario crear otra contraseña!), aumenta las conversiones y un resultado deseable.

El inicio de sesión social se basa en la federación de identidad, donde delegamos el paso de verificación de identidad a un proveedor de identidad externo, quien a su vez comparte con nosotros la información que solicitamos (como la dirección de correo electrónico, el nombre completo y la fecha de nacimiento).

Las implementaciones comunes de identidad federada se basan en OpenID Connect, una capa de identidad sobre el estándar OAuth2.

4.6 Máquina a máquina, representando a una persona

También existe una situación en la que una persona autoriza una máquina (por ejemplo, un servicio de terceros) para realizar acciones contra nuestra API en su nombre. Por ejemplo, una aplicación móvil que proporciona una interfaz de usuario alternativa para Twitter.

Es importante resaltar cómo esto difiere del primer escenario que revisamos (autenticación pura de máquina a máquina). En este caso, el servicio de terceros no tiene derecho a realizar ninguna operación solo en nuestra API. Los servicios de terceros solo pueden realizar acciones en nuestra API si el usuario les otorga acceso, limitado a su conjunto de permisos. Puedo instalar una aplicación móvil para twittear en mi nombre, pero no puedo autorizarla a twittear en nombre de David Guetta.

La autenticación "básica" es muy inapropiada aquí: no queremos compartir nuestras contraseñas con aplicaciones de terceros. Cuantas más personas vean nuestras contraseñas, más probabilidades hay de que se vean comprometidas.

Además, mantener un registro de auditoría con credenciales compartidas es una pesadilla. Cuando las cosas van mal, es imposible saber quién hizo qué: ¿realmente fui yo? ¿Es una de las 20 aplicaciones con las que comparto credenciales? ¿Quién es responsable?

Este es un escenario de libro de texto para OAuth 2, donde los terceros nunca ven nuestro nombre de usuario y contraseña. Reciben un token de acceso opaco del servidor de autenticación, que nuestra API sabe cómo verificar para otorgar (o denegar) el acceso.

5. ¿Qué debemos hacer a continuación?

Los navegadores son nuestro objetivo principal, está decidido. ¡Nuestra estrategia de autenticación debe evolucionar en consecuencia!

Comenzaremos convirtiendo nuestro flujo de autenticación "básico" en un formulario de inicio de sesión con autenticación basada en sesiones. Vamos a crear un panel de administración desde cero. Incluirá un formulario de inicio de sesión, un enlace de cierre de sesión y un formulario para cambiar la contraseña. Nos dará la oportunidad de discutir algunos desafíos de seguridad (como XSS), introducir nuevos conceptos (como cookies, etiquetas HMAC) y probar nuevas herramientas (como mensajes flash actix-session).

¡Esta será la hoja de ruta para el próximo episodio! ¡adiós!

5. Notas al pie

1  base64-encoding garantiza que todos los caracteres en la salida sean ASCII, pero no ofrece protección: no se necesita ningún secreto para la decodificación. En otras palabras, ¡la codificación no es encriptación!
5  Suponiendo que el espacio de entrada es limitado (es decir, la longitud de la contraseña tiene un límite superior), una función hash perfecta f(x) == f(y) implica que x == y se puede encontrar teóricamente.
2  Al investigar ataques de fuerza bruta, a menudo verá menciones de tablas arcoíris, una estructura de datos eficiente para la precomputación y la búsqueda de hashes.
3  Este cálculo aproximado debería mostrar claramente que incluso si el servidor almacena contraseñas utilizando un algoritmo hash rápido, el uso de contraseñas generadas aleatoriamente le brinda a usted, como usuario, un nivel significativo de protección contra ataques de fuerza bruta. las formas más fáciles de asegurar un perfil.
4  En general, OWASP es un tesoro de material educativo de calidad sobre seguridad de aplicaciones Web. Debe familiarizarse lo más posible con el material de OWASP, especialmente si no cuenta con un experto en seguridad de aplicaciones en su equipo/organización que lo apoye. Además de la hoja de trucos que vinculamos, asegúrese de explorar sus Criterios de verificación de seguridad de la aplicación.
6  Es por esto que OWASP recomienda agregar una capa adicional de defensa. Todos los hashes almacenados en la base de datos se cifran con un secreto compartido conocido solo por la aplicación. Sin embargo, el cifrado también presenta su propio conjunto de desafíos: ¿dónde almacenaremos las claves? ¿Cómo lo rotamos? La respuesta suele implicar un módulo de seguridad de hardware (HSM) o una bóveda secreta, como AWS CloudHSM, AWS KMS o Hashicorp Vault. Una descripción completa de la administración de claves está más allá del alcance de este libro.
7 No profundicé en el código fuente de los diferentes algoritmos hash implementados por PasswordVerifier, pero me pregunto por qué verificar_contraseña necesita tomar &self como parámetro. Argon2 es absolutamente inútil, pero nos obliga a pasar un Argon2::default para llamar a verificar_contraseña.8
PasswordVerifier  ::verificar_contraseña también hace algo que se basa en Salida para comparar dos hashes, en lugar de usar bytes sin procesar. Las implementaciones de salida PartialEq y Eq están diseñadas para ser evaluadas en tiempo constante, no importa cuán diferentes o similares sean las entradas, la ejecución de la función tomará la misma cantidad de tiempo. Suponiendo que el atacante tenga pleno conocimiento de la configuración del algoritmo hash que utiliza el servidor, puede analizar el tiempo de respuesta de cada intento de autenticación para deducir el primer byte del hash de la contraseña, que, combinado con un diccionario, puede ayudarlos a descifrar la contraseña. La viabilidad de este ataque es discutible, más aún cuando existe salazón. Aún así, no nos costó nada, y es mejor prevenir que curar.
9  Nuestros ejemplos están intencionalmente simplificados. En efecto, cada uno de estos estados tendrá a su vez subestados que esperan cada estado en el cuerpo de la función que estamos llamando. ¡El futuro puede convertirse en una máquina de estado profundamente anidada!
10  Esta heurística se informa en "Async: What is Blocking?" Contribución de Alice Rhyl, una de las mantenedoras de tokio. ¡Le recomiendo encarecidamente que lea un artículo para comprender mejor su mecanismo básico de tokio async/await!
11  En un escenario de la vida real, existe una red entre el atacante y su servidor. Las diferencias de carga y de red pueden enmascarar las diferencias de velocidad para un conjunto limitado de intentos, pero si recopila suficientes puntos de datos, debería poder notar una diferencia estadísticamente significativa en la latencia.
12  Es por eso que nunca debe ingresar sus contraseñas en un sitio web que no use HTTPS, es decir, HTTP + TLS.
13 Implementar un formulario de inicio de sesión seguro es su propio desafío, ¡hola CSRF! Lo veremos más de cerca más adelante en este capítulo.

Supongo que te gusta

Origin blog.csdn.net/zmule/article/details/126549326
Recomendado
Clasificación