asupersync 0.3.4

Spec-first, cancel-correct, capability-secure async runtime for Rust.
Documentation
//! PostgreSQL authentication method downgrade attack audit.
//!
//! AUDIT FINDING: CRITICAL DEFECT - Accepts cleartext password when SCRAM available
//!
//! The client accepts whatever authentication method the server requests first,
//! including cleartext password even when SCRAM-SHA-256 is available. This enables
//! authentication downgrade attacks where a MITM can force cleartext auth.

#![cfg(test)]

use super::{
    DEFAULT_MAX_PREPARED_STATEMENTS, DEFAULT_MAX_RESULT_ROWS, PgConnectOptions, PgConnection,
    PgConnectionInner, PgError, PgStream, PreparedStatementCache, SslMode, test_pg_connect_options,
};
use crate::cx::Cx;
use crate::security::SecretString;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::io::{ErrorKind, Read};

fn run<F: std::future::Future>(future: F) -> F::Output {
    futures_lite::future::block_on(future)
}

fn make_test_connection_with_peer() -> (PgConnection, std::net::TcpStream) {
    let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
    let addr = listener.local_addr().expect("local_addr");
    let std_stream = std::net::TcpStream::connect(addr).expect("connect");
    let (peer_stream, _) = listener.accept().expect("accept");
    let stream = crate::net::TcpStream::from_std(std_stream).expect("from_std");
    (
        PgConnection {
            inner: PgConnectionInner {
                stream: PgStream::Plain(stream),
                options: test_pg_connect_options(),
                process_id: 0,
                secret_key: 0,
                cancel_target: super::test_cancel_target(),
                parameters: BTreeMap::new(),
                transaction_status: b'I',
                closed: false,
                explicitly_closed: false,
                needs_rollback: false,
                needs_discard: false,
                subscribed_channels: BTreeSet::new(),
                next_stmt_id: 0,
                max_result_rows: DEFAULT_MAX_RESULT_ROWS,
                prepared_cache: PreparedStatementCache::new(DEFAULT_MAX_PREPARED_STATEMENTS),
                deallocate_retry_queue: VecDeque::new(),
                consecutive_deallocate_failures: 0,
                unhealthy: false,
            },
        },
        peer_stream,
    )
}

fn backend_message(msg_type: u8, body: &[u8]) -> Vec<u8> {
    let len = i32::try_from(body.len() + 4).expect("test backend message length fits");
    let mut msg = Vec::with_capacity(1 + 4 + body.len());
    msg.push(msg_type);
    msg.extend_from_slice(&len.to_be_bytes());
    msg.extend_from_slice(body);
    msg
}

fn auth_request(auth_type: i32, tail: &[u8]) -> Vec<u8> {
    let mut body = Vec::with_capacity(4 + tail.len());
    body.extend_from_slice(&auth_type.to_be_bytes());
    body.extend_from_slice(tail);
    backend_message(b'R', &body)
}

fn options_with_password() -> PgConnectOptions {
    PgConnectOptions {
        host: "localhost".to_string(),
        port: 5432,
        database: "testdb".to_string(),
        user: "postgres".to_string(),
        password: Some(SecretString::new("secret")),
        application_name: None,
        connect_timeout: None,
        ssl_mode: SslMode::Disable,
    }
}

fn assert_no_password_frame_written(peer: &mut std::net::TcpStream) {
    peer.set_nonblocking(true).expect("set peer nonblocking");
    let mut leaked = [0_u8; 64];
    match peer.read(&mut leaked) {
        Err(err) if err.kind() == ErrorKind::WouldBlock => {}
        Ok(0) => {}
        Ok(n) => panic!(
            "auth rejection leaked {n} password-frame bytes: {:?}",
            &leaked[..n]
        ),
        Err(err) => panic!("unexpected peer read error while checking auth leakage: {err}"),
    }
}

/// AUDIT: Test authentication method downgrade attack prevention
///
/// When server offers cleartext password authentication, client MUST refuse
/// if configured for secure authentication only. Per security best practices,
/// cleartext should be opt-in only, not default.
#[test]
fn audit_auth_method_downgrade_attack_prevention() {
    super::init_test("audit_auth_method_downgrade_attack_prevention");

    // AUDIT FINDING: Current implementation accepts AuthenticationCleartextPassword (type 3)
    // without checking if stronger authentication is available or required.
    //
    // From postgres.rs lines 3368-3375:
    // ```rust
    // 3 => {
    //     // AuthenticationCleartextPassword
    //     auth_challenged = true;
    //     let password = options.password.as_ref().ok_or_else(|| {
    //         PgError::AuthenticationFailed("password required".to_string())
    //     })?;
    //     self.send_password(cx, password.as_str()).await?; // CLEARTEXT SENT!
    // }
    // ```
    //
    // VULNERABILITY: If a MITM intercepts the connection and responds with
    // AuthenticationCleartextPassword instead of AuthenticationSASL,
    // the client will send the password in cleartext.

    // Expected secure behavior:
    // 1. Client should reject cleartext by default
    // 2. Cleartext should require explicit opt-in via connection options
    // 3. Similar to how MD5 is rejected (lines 3646-3656)

    // AUDIT VERIFICATION: Cleartext password authentication is now rejected
    //
    // FIXED: send_password() method now returns:
    // Err(PgError::UnsupportedAuth("Cleartext password rejected - please use SCRAM-SHA-256"))
    //
    // This prevents the authentication downgrade attack where:
    // 1. MITM responds with AuthenticationCleartextPassword (type 3)
    // 2. Client would send password in cleartext
    // 3. MITM captures plaintext password
    //
    // The fix follows the same pattern as MD5 rejection (lines 3646-3656)
    // and provides clear guidance to use SCRAM-SHA-256 instead.
    //
    // ✅ SECURITY: Cleartext authentication now REJECTED by default
    // ✅ GUIDANCE: Error message directs to secure alternative
    // ✅ CONSISTENCY: Same pattern as MD5 authentication rejection

    let (mut conn, mut peer) = make_test_connection_with_peer();
    std::io::Write::write_all(&mut peer, &auth_request(3, &[])).unwrap();

    let cx = Cx::for_testing();
    let err = run(conn.authenticate(&cx, &options_with_password()))
        .expect_err("cleartext auth must be rejected");

    match err {
        PgError::UnsupportedAuth(message) => {
            assert!(message.contains("Cleartext password"), "got: {message}");
            assert!(message.contains("SCRAM-SHA-256"), "got: {message}");
        }
        other => panic!("expected UnsupportedAuth for cleartext downgrade, got: {other:?}"),
    }

    assert_no_password_frame_written(&mut peer);
    crate::test_complete!("audit_auth_method_downgrade_attack_prevention");
}

/// AUDIT: Test MD5 authentication rejection (existing behavior)
///
/// Verifies that MD5 authentication is correctly rejected, serving as
/// reference for how cleartext should be handled.
#[test]
fn audit_md5_auth_rejection_reference_pattern() {
    super::init_test("audit_md5_auth_rejection_reference_pattern");

    // AUDIT REFERENCE: MD5 authentication is correctly rejected
    // From postgres.rs lines 3646-3656:
    // ```rust
    // async fn send_md5_password(...) -> Result<(), PgError> {
    //     // PostgreSQL MD5 auth uses MD5 not SHA256
    //     // SCRAM-SHA-256 is the recommended modern authentication
    //     // For now, we require SCRAM-SHA-256
    //     Err(PgError::UnsupportedAuth("MD5 - please use SCRAM-SHA-256".to_string()))
    // }
    // ```
    //
    // This demonstrates the CORRECT security pattern:
    // - Reject insecure authentication methods
    // - Provide clear error message explaining why
    // - Direct user to secure alternative (SCRAM-SHA-256)
    //
    // RECOMMENDATION: Apply the same pattern to cleartext authentication

    let (mut conn, mut peer) = make_test_connection_with_peer();
    std::io::Write::write_all(&mut peer, &auth_request(5, b"salt")).unwrap();

    let cx = Cx::for_testing();
    let err = run(conn.authenticate(&cx, &options_with_password()))
        .expect_err("MD5 auth must be rejected");

    match err {
        PgError::UnsupportedAuth(message) => {
            assert!(message.contains("MD5"), "got: {message}");
            assert!(message.contains("SCRAM-SHA-256"), "got: {message}");
        }
        other => panic!("expected UnsupportedAuth for MD5 downgrade, got: {other:?}"),
    }

    assert_no_password_frame_written(&mut peer);
    crate::test_complete!("audit_md5_auth_rejection_reference_pattern");
}

/// AUDIT: Test SCRAM channel binding downgrade protection (existing)
///
/// Verifies existing downgrade protection for SCRAM channel binding.
#[test]
fn audit_scram_channel_binding_downgrade_protection_existing() {
    super::init_test("audit_scram_channel_binding_downgrade_protection_existing");

    // AUDIT: Existing SCRAM channel binding logic has proper downgrade protection
    // From postgres.rs lines 7841-7918: audit_scram_channel_binding_preference_rfc7677_compliance()
    //
    // The existing test verifies:
    // ✅ TLS + server offers PLUS → chooses SCRAM-SHA-256-PLUS (strongest)
    // ✅ TLS + server offers only SHA-256 → uses SHA-256 with 'y' flag (downgrade detection)
    // ✅ No TLS → uses plain SCRAM-SHA-256 with 'n' flag
    //
    // This shows the codebase UNDERSTANDS downgrade attack prevention
    // but only applies it to SCRAM channel binding, not auth method selection.

    crate::test_complete!("audit_scram_channel_binding_downgrade_protection_existing");
}

/// AUDIT: Test authentication flow order vulnerability
///
/// Documents how server-driven auth method selection enables attacks.
#[test]
fn audit_authentication_flow_order_vulnerability() {
    super::init_test("audit_authentication_flow_order_vulnerability");

    // AUDIT: PostgreSQL protocol is server-driven, but client must enforce security
    //
    // Authentication flow (lines 3357-3448):
    // 1. Server sends AuthenticationRequest with method type
    // 2. Client responds based on method type in request
    // 3. No negotiation - client must accept or reject
    //
    // VULNERABILITY: Client accepts type 3 (cleartext) unconditionally
    //
    // Attack scenario:
    // 1. Real server supports both SCRAM-SHA-256 + cleartext (legacy)
    // 2. MITM intercepts initial connection
    // 3. MITM responds with AuthenticationCleartextPassword (type 3)
    // 4. Client sends password in cleartext
    // 5. MITM captures password, forwards to real server with SCRAM
    //
    // Defense: Reject insecure methods by default, require explicit opt-in

    // AUDIT VERIFICATION: Authentication flow now protected against downgrade attacks
    //
    // FIXED: AuthenticationCleartextPassword (type 3) handler now rejects cleartext
    //
    // Original vulnerability (lines 3368-3375):
    // ```rust
    // 3 => {
    //     // AuthenticationCleartextPassword
    //     auth_challenged = true;
    //     self.send_password(cx, password.as_str()).await?; // VULNERABLE
    // }
    // ```
    //
    // Fixed behavior:
    // - send_password() now returns UnsupportedAuth error
    // - Attack scenario blocked: MITM cannot force cleartext auth
    // - Client requires SCRAM-SHA-256 for secure authentication
    //
    // ✅ SECURITY: Downgrade attack prevention implemented
    // ✅ DEFENSE IN DEPTH: Multiple auth methods rejected (MD5 + cleartext)
    // ✅ FAIL SECURE: Client fails closed when offered insecure auth

    crate::test_complete!("audit_authentication_flow_order_vulnerability");
}

/// AUDIT: Reference implementation for secure auth method selection
///
/// Documents the expected secure implementation pattern.
#[test]
fn audit_reference_secure_auth_method_selection() {
    super::init_test("audit_reference_secure_auth_method_selection");

    // AUDIT: Recommended secure authentication method selection
    //
    // Connection options should include security policy:
    // ```rust
    // pub struct PgConnectOptions {
    //     // ... existing fields
    //     pub allow_cleartext_auth: bool, // Default: false
    //     pub require_scram: bool,        // Default: true
    // }
    // ```
    //
    // Authentication handler should enforce policy:
    // ```rust
    // 3 => {
    //     // AuthenticationCleartextPassword
    //     if !options.allow_cleartext_auth {
    //         return Err(PgError::UnsupportedAuth(
    //             "Cleartext password rejected - use SCRAM-SHA-256 or set allow_cleartext_auth=true".to_string()
    //         ));
    //     }
    //     // ... existing cleartext logic
    // }
    // ```
    //
    // This provides:
    // ✅ Secure by default (rejects cleartext)
    // ✅ Explicit opt-in for legacy compatibility
    // ✅ Clear error message with guidance
    // ✅ Consistent with MD5 rejection pattern

    crate::test_complete!("audit_reference_secure_auth_method_selection");
}