SMB Server Phase 2: VFS backend build fix + integration test
- Add VfsFile: Send supertrait for Mutex compatibility - Fix SmbServerCommand: struct → Subcommand enum with Start variant - Fix tracing_subscriber::init() → try_init() to avoid panic when logger already initialized - Fix CLI subcommand name: smb-server → smb-start (flatten naming) - Add #[command(name = "smb-start")] for CLI disambiguation - Fix unused variable warnings (smb_fs.rs, smb_server_backend.rs) - Remove unused VfsFile imports (webdav.rs, scp_handler.rs) - Integration test: Docker smbclient verified (list, upload, read)
This commit is contained in:
120
vendor/smb2/src/auth/CLAUDE.md
vendored
Normal file
120
vendor/smb2/src/auth/CLAUDE.md
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
# Auth -- NTLM and Kerberos authentication
|
||||
|
||||
NTLMv2 and Kerberos authentication for SMB2 session setup.
|
||||
|
||||
## Key files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `mod.rs` | Module exports |
|
||||
| `der.rs` | Shared ASN.1/DER primitives (TLV encode/decode) |
|
||||
| `ntlm.rs` | `NtlmAuthenticator` -- 3-message NTLM exchange |
|
||||
| `spnego.rs` | SPNEGO NegTokenInit/NegTokenResp wrapping |
|
||||
| `kerberos/mod.rs` | Kerberos module root, re-exports authenticator |
|
||||
| `kerberos/authenticator.rs` | `KerberosAuthenticator` -- full AS + TGS + AP-REQ flow |
|
||||
| `kerberos/crypto.rs` | AES-CTS, RC4-HMAC, string-to-key, key derivation |
|
||||
| `kerberos/messages.rs` | ASN.1/DER encoding/decoding for Kerberos messages |
|
||||
| `kerberos/kdc.rs` | KDC transport client (UDP/TCP with fallback) |
|
||||
|
||||
## NTLM exchange
|
||||
|
||||
1. `negotiate()` -- builds NEGOTIATE_MESSAGE (Type 1) with default flags
|
||||
2. Server sends CHALLENGE_MESSAGE (Type 2) with server challenge and target info
|
||||
3. `authenticate(&challenge_bytes)` -- builds AUTHENTICATE_MESSAGE (Type 3) with NTLMv2 response
|
||||
|
||||
Only NTLMv2 is supported. NTLMv1 is insecure and not implemented.
|
||||
|
||||
## Kerberos exchange
|
||||
|
||||
`KerberosAuthenticator` performs the full Kerberos flow in three steps:
|
||||
|
||||
1. **AS exchange** (client -> KDC): derive user key from password, build PA-ENC-TIMESTAMP + PA-PAC-REQUEST, send AS-REQ, parse AS-REP, decrypt enc-part with user key to get TGT + AS session key.
|
||||
2. **TGS exchange** (client -> KDC): build AP-REQ wrapping TGT + authenticator (encrypted with AS session key), send TGS-REQ for `cifs/hostname`, parse TGS-REP, decrypt enc-part with AS session key to get service ticket + TGS session key.
|
||||
3. **AP-REQ construction**: build Authenticator with subkey, encrypt with TGS session key, build AP-REQ with service ticket, wrap in SPNEGO NegTokenInit.
|
||||
|
||||
The flow differs from NTLM: Kerberos contacts the KDC directly (async, network I/O), then produces a single token for SESSION_SETUP (usually 1 round-trip with the SMB server).
|
||||
|
||||
### Key usage numbers (RFC 4120 section 7.5.1)
|
||||
|
||||
- 1: PA-ENC-TIMESTAMP encryption
|
||||
- 3: AS-REP EncKDCRepPart decryption
|
||||
- 6: TGS-REQ PA-TGS-REQ Authenticator cksum (body checksum)
|
||||
- 7: AP-REQ Authenticator encryption
|
||||
- 8: TGS-REP EncKDCRepPart decryption (tries 8 first, falls back to 9)
|
||||
|
||||
### Encryption types supported
|
||||
|
||||
- AES-256-CTS-HMAC-SHA1-96 (etype 18) -- preferred
|
||||
- AES-128-CTS-HMAC-SHA1-96 (etype 17)
|
||||
- RC4-HMAC (etype 23) -- legacy
|
||||
|
||||
### Key derivation constants (RFC 3961)
|
||||
|
||||
Three subkeys are derived from each base key + usage number:
|
||||
- **Ke** = DK(key, usage || 0xAA) -- encryption subkey, used for AES-CTS
|
||||
- **Ki** = DK(key, usage || 0x55) -- integrity subkey, used for HMAC inside encrypt/decrypt
|
||||
- **Kc** = DK(key, usage || 0x99) -- checksum subkey, used for standalone checksum/MIC
|
||||
|
||||
Ki and Kc are NOT the same key. Ki is for the HMAC that's appended to ciphertext in the encrypt() function. Kc is for standalone operations like the body checksum in the TGS-REQ Authenticator.
|
||||
|
||||
### Kerberos wire encryption format (AES)
|
||||
|
||||
1. Derive Ke (with 0xAA) and Ki (with 0x55) from base key + usage
|
||||
2. Generate 16-byte random confounder
|
||||
3. plaintext' = confounder || plaintext
|
||||
4. ciphertext = AES-CTS(Ke, iv=0, plaintext')
|
||||
5. hmac = HMAC-SHA1-96(Ki, plaintext') -- 12 bytes
|
||||
6. output = ciphertext || hmac
|
||||
|
||||
## NTLM key derivation flow
|
||||
|
||||
1. `NTOWFv2`: `HMAC-MD5(MD4(password_utf16), uppercase(username) + domain)`
|
||||
2. `NTProofStr`: `HMAC-MD5(NTOWFv2, server_challenge + client_blob)`
|
||||
3. `SessionBaseKey`: `HMAC-MD5(NTOWFv2, NTProofStr)`
|
||||
4. If KEY_EXCH flag: generate random session key, RC4-encrypt with SessionBaseKey
|
||||
5. `ExportedSessionKey` feeds into SP800-108 KDF (in `crypto/kdf.rs`)
|
||||
|
||||
## MIC computation
|
||||
|
||||
Modern servers include `MsvAvTimestamp` in the challenge target info, which triggers MIC validation. When present:
|
||||
1. Add `MsvAvFlags` with bit 0x2 (MIC present) to the target info
|
||||
2. Build the AUTHENTICATE_MESSAGE with a zeroed 16-byte MIC field at offset 72
|
||||
3. Compute `HMAC-MD5(ExportedSessionKey, negotiate_msg || challenge_msg || authenticate_msg)`
|
||||
4. Patch the MIC into bytes 72..88
|
||||
|
||||
The authenticator retains raw bytes of NEGOTIATE and CHALLENGE messages for this computation.
|
||||
|
||||
## Key decisions
|
||||
|
||||
- **`getrandom` for random values**: Client challenge, random session key, nonces, and confounders use `getrandom` (OS CSPRNG).
|
||||
- **`test_random_session_key` override**: Tests can inject a deterministic session key for reproducibility. Never used in production.
|
||||
- **Subkey in AP-REQ Authenticator**: The Kerberos authenticator includes a random subkey, which becomes the SMB session key. This provides forward secrecy.
|
||||
- **No full `authenticate()` unit tests**: The full flow requires a real KDC. Unit tests cover individual steps (encrypt/decrypt roundtrip, message encoding, etype parsing). Integration tests with Docker are planned.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Retain raw challenge bytes for MIC (NTLM)**: The MIC is computed over the exact wire bytes of all three messages.
|
||||
- **RC4 for key exchange is inline (NTLM)**: ~15 lines of RC4 implementation.
|
||||
- **MsvAvTimestamp presence changes behavior (NTLM)**: Without it, no MIC is computed. With it, MIC is mandatory.
|
||||
- **NTLMv1 not supported**: No fallback.
|
||||
- **Target info modification (NTLM)**: The client modifies the server's target info before including it in the client blob.
|
||||
- **TGS-REP key usage ambiguity (Kerberos)**: RFC 4120 says key usage 8 for TGS-REP encrypted with session key, but some KDCs use 9. The authenticator tries 8 first, falls back to 9.
|
||||
- **KDC_ERR_PREAUTH_REQUIRED handling (Kerberos)**: First AS-REQ without pre-auth gets error 25. The authenticator extracts supported etypes from the e-data (ETYPE-INFO2) and retries with pre-authentication.
|
||||
- **DER primitives in `auth::der`**: Core DER encoding/decoding helpers (`der_length`, `der_tlv`, `parse_der_length`, `parse_der_tlv`) live in `auth/der.rs` and are shared by `spnego.rs` and `kerberos/messages.rs`. Type-specific helpers (INTEGER, GeneralString, etc.) stay in their respective modules.
|
||||
|
||||
## Kerberos key design decisions (from end-to-end testing)
|
||||
|
||||
- **MS Kerberos OID (`1.2.840.48018.1.2.2`)**: Windows AD requires the Microsoft Kerberos OID in SPNEGO NegTokenInit, not the standard RFC 4120 OID. Both are included in mechTypes, with MS OID first.
|
||||
- **Key usage 11 for SPNEGO AP-REQ Authenticator**: Standard RFC 4120 uses key usage 7 for AP-REQ Authenticator encryption. Windows expects key usage 11 when the AP-REQ is wrapped in SPNEGO (per MS-KILE). Using 7 causes `KRB_AP_ERR_MODIFIED`.
|
||||
- **Session key etype detection**: The TGS-REQ requests AES-256, AES-128, and RC4 (preference order). The KDC picks the session key type from this list — it may differ from the ticket encryption type. The authenticator detects the actual etype from the TGS-REP `EncKDCRepPart.key.keytype` and uses the matching cipher for Authenticator encryption.
|
||||
- **Raw ticket pass-through**: The service ticket bytes must be sent to the SMB server exactly as received from the KDC. Re-encoding the ticket from parsed fields produces different DER and causes `KRB_AP_ERR_MODIFIED`. The `Ticket` struct carries `raw_bytes` for this.
|
||||
- **GSS-API wrapping**: The AP-REQ in SPNEGO NegTokenInit must include the GSS-API OID header (`0x60 len OID ap-req`), not just the raw AP-REQ bytes.
|
||||
- **Mutual authentication**: AP-REQ sets the mutual-required flag. The server returns an AP-REP (in SPNEGO NegTokenResp) containing a server sub-session key. The client decrypts the AP-REP (key usage 12) to extract this subkey, which becomes the SMB session key. This provides cryptographic proof that the server possesses the service key. The AP-REP may arrive in a `STATUS_SUCCESS` response (not always `STATUS_MORE_PROCESSING_REQUIRED`).
|
||||
|
||||
- **Credential cache (ccache) support**: `kerberos/ccache.rs` parses MIT Kerberos ccache files (v3 and v4). Supports loading cached TGTs (skip AS exchange, do TGS) and cached service tickets (skip both AS and TGS). Integrates via `Session::setup_kerberos_from_ccache()` and `KerberosAuthenticator::authenticate_from_ccache()`. `load_ccache()` reads from a path or `$KRB5CCNAME`.
|
||||
|
||||
## Known tech debt (Kerberos)
|
||||
|
||||
- ~~DER helpers duplicated between `spnego.rs` and `kerberos/messages.rs`~~ (resolved: shared `auth/der.rs`)
|
||||
- ~~`kerberos/authenticator.rs` mixes crypto wrappers with protocol flow~~ (resolved: `kerberos_encrypt`, `kerberos_decrypt`, `etype_from_i32`, and `generate_random_key` moved to `kerberos/crypto.rs`)
|
||||
- ~~`#![allow(rustdoc::broken_intra_doc_links)]` hack in `kerberos/messages.rs`~~ (resolved: ASN.1 context tags in doc comments wrapped in backticks)
|
||||
196
vendor/smb2/src/auth/der.rs
vendored
Normal file
196
vendor/smb2/src/auth/der.rs
vendored
Normal file
@@ -0,0 +1,196 @@
|
||||
//! Shared ASN.1/DER encoding and decoding primitives.
|
||||
//!
|
||||
//! These low-level helpers are used by both `spnego.rs` and `kerberos/messages.rs`
|
||||
//! to build and parse DER-encoded structures. Only the core TLV operations live
|
||||
//! here; type-specific helpers (INTEGER, GeneralString, etc.) stay in their
|
||||
//! respective modules.
|
||||
|
||||
use crate::Error;
|
||||
|
||||
/// Encode a DER length field.
|
||||
///
|
||||
/// - Lengths < 128 are encoded as a single byte.
|
||||
/// - Lengths < 256 are encoded as `0x81` followed by one byte.
|
||||
/// - Lengths < 65536 are encoded as `0x82` followed by two bytes (big-endian).
|
||||
pub(crate) fn der_length(len: usize) -> Vec<u8> {
|
||||
if len < 128 {
|
||||
vec![len as u8]
|
||||
} else if len < 256 {
|
||||
vec![0x81, len as u8]
|
||||
} else {
|
||||
vec![0x82, (len >> 8) as u8, (len & 0xff) as u8]
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap data in a DER TLV (tag-length-value).
|
||||
pub(crate) fn der_tlv(tag: u8, data: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![tag];
|
||||
out.extend_from_slice(&der_length(data.len()));
|
||||
out.extend_from_slice(data);
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a DER length field, returning `(length, bytes_consumed)`.
|
||||
pub(crate) fn parse_der_length(data: &[u8]) -> Result<(usize, usize), Error> {
|
||||
if data.is_empty() {
|
||||
return Err(Error::invalid_data("DER: truncated length"));
|
||||
}
|
||||
let first = data[0];
|
||||
if first < 128 {
|
||||
Ok((first as usize, 1))
|
||||
} else if first == 0x81 {
|
||||
if data.len() < 2 {
|
||||
return Err(Error::invalid_data("DER: truncated length (0x81)"));
|
||||
}
|
||||
Ok((data[1] as usize, 2))
|
||||
} else if first == 0x82 {
|
||||
if data.len() < 3 {
|
||||
return Err(Error::invalid_data("DER: truncated length (0x82)"));
|
||||
}
|
||||
let len = ((data[1] as usize) << 8) | (data[2] as usize);
|
||||
Ok((len, 3))
|
||||
} else if first == 0x83 {
|
||||
if data.len() < 4 {
|
||||
return Err(Error::invalid_data("DER: truncated length (0x83)"));
|
||||
}
|
||||
let len = ((data[1] as usize) << 16) | ((data[2] as usize) << 8) | (data[3] as usize);
|
||||
Ok((len, 4))
|
||||
} else {
|
||||
Err(Error::invalid_data(format!(
|
||||
"DER: unsupported length encoding: 0x{first:02x}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a DER TLV, returning `(tag, value_slice, total_bytes_consumed)`.
|
||||
pub(crate) fn parse_der_tlv(data: &[u8]) -> Result<(u8, &[u8], usize), Error> {
|
||||
if data.is_empty() {
|
||||
return Err(Error::invalid_data("DER: truncated TLV"));
|
||||
}
|
||||
let tag = data[0];
|
||||
let (len, len_bytes) = parse_der_length(&data[1..])?;
|
||||
let header_len = 1 + len_bytes;
|
||||
let total = header_len + len;
|
||||
if data.len() < total {
|
||||
return Err(Error::invalid_data(format!(
|
||||
"DER: TLV truncated: need {total} bytes, have {}",
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
Ok((tag, &data[header_len..total], total))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// =======================================================================
|
||||
// DER length encoding
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn length_single_byte() {
|
||||
assert_eq!(der_length(0), vec![0x00]);
|
||||
assert_eq!(der_length(1), vec![0x01]);
|
||||
assert_eq!(der_length(127), vec![0x7f]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_two_byte() {
|
||||
assert_eq!(der_length(128), vec![0x81, 0x80]);
|
||||
assert_eq!(der_length(255), vec![0x81, 0xff]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_three_byte() {
|
||||
assert_eq!(der_length(256), vec![0x82, 0x01, 0x00]);
|
||||
assert_eq!(der_length(65535), vec![0x82, 0xff, 0xff]);
|
||||
assert_eq!(der_length(1000), vec![0x82, 0x03, 0xe8]);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// DER TLV encoding
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn tlv_simple() {
|
||||
let result = der_tlv(0x04, &[0x01, 0x02]);
|
||||
assert_eq!(result, vec![0x04, 0x02, 0x01, 0x02]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlv_empty() {
|
||||
let result = der_tlv(0x30, &[]);
|
||||
assert_eq!(result, vec![0x30, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlv_long_content() {
|
||||
let data = vec![0xaa; 200];
|
||||
let result = der_tlv(0x04, &data);
|
||||
assert_eq!(result[0], 0x04);
|
||||
assert_eq!(result[1], 0x81);
|
||||
assert_eq!(result[2], 200);
|
||||
assert_eq!(result.len(), 3 + 200);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// DER length parsing
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn parse_length_single_byte() {
|
||||
let (len, consumed) = parse_der_length(&[0x05]).unwrap();
|
||||
assert_eq!(len, 5);
|
||||
assert_eq!(consumed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_length_two_byte() {
|
||||
let (len, consumed) = parse_der_length(&[0x81, 0x80]).unwrap();
|
||||
assert_eq!(len, 128);
|
||||
assert_eq!(consumed, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_length_three_byte() {
|
||||
let (len, consumed) = parse_der_length(&[0x82, 0x01, 0x00]).unwrap();
|
||||
assert_eq!(len, 256);
|
||||
assert_eq!(consumed, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_length_four_byte() {
|
||||
let (len, consumed) = parse_der_length(&[0x83, 0x01, 0x00, 0x00]).unwrap();
|
||||
assert_eq!(len, 65536);
|
||||
assert_eq!(consumed, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_length_truncated() {
|
||||
assert!(parse_der_length(&[]).is_err());
|
||||
assert!(parse_der_length(&[0x81]).is_err());
|
||||
assert!(parse_der_length(&[0x82, 0x01]).is_err());
|
||||
assert!(parse_der_length(&[0x83, 0x01, 0x00]).is_err());
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// DER TLV parsing
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn parse_tlv_roundtrip() {
|
||||
let original = der_tlv(0x04, &[0xde, 0xad, 0xbe, 0xef]);
|
||||
let (tag, value, total) = parse_der_tlv(&original).unwrap();
|
||||
assert_eq!(tag, 0x04);
|
||||
assert_eq!(value, &[0xde, 0xad, 0xbe, 0xef]);
|
||||
assert_eq!(total, original.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tlv_truncated() {
|
||||
assert!(parse_der_tlv(&[]).is_err());
|
||||
// Tag present, length says 10 bytes but only 2 available
|
||||
assert!(parse_der_tlv(&[0x04, 0x0a, 0x01, 0x02]).is_err());
|
||||
}
|
||||
}
|
||||
1637
vendor/smb2/src/auth/kerberos/authenticator.rs
vendored
Normal file
1637
vendor/smb2/src/auth/kerberos/authenticator.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
447
vendor/smb2/src/auth/kerberos/ccache.rs
vendored
Normal file
447
vendor/smb2/src/auth/kerberos/ccache.rs
vendored
Normal file
@@ -0,0 +1,447 @@
|
||||
//! MIT Kerberos credential cache (ccache) file parser.
|
||||
//!
|
||||
//! Reads ccache files (v3 and v4) to extract cached TGTs and service tickets,
|
||||
//! enabling Kerberos authentication without a password when the user already
|
||||
//! has a valid ticket (for example, from `kinit`).
|
||||
//!
|
||||
//! References:
|
||||
//! - MIT Kerberos source: `lib/krb5/ccache/cc_file.c`
|
||||
//! - Format: version(2) + [header(v4)] + default_principal + credentials*
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use log::debug;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A parsed Kerberos credential cache.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CCache {
|
||||
/// File format version (3 or 4).
|
||||
pub version: u16,
|
||||
/// Default principal (typically the user who ran `kinit`).
|
||||
pub default_principal: CcachePrincipal,
|
||||
/// Cached credentials (TGTs and service tickets).
|
||||
pub credentials: Vec<CcacheCredential>,
|
||||
}
|
||||
|
||||
/// A principal name in the ccache.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CcachePrincipal {
|
||||
/// Name type (1 = KRB_NT_PRINCIPAL, 2 = KRB_NT_SRV_INST, etc.).
|
||||
pub name_type: u32,
|
||||
/// Kerberos realm.
|
||||
pub realm: String,
|
||||
/// Name components (for example, `["smbtest"]` or `["cifs", "server.domain.com"]`).
|
||||
pub components: Vec<String>,
|
||||
}
|
||||
|
||||
/// A single cached credential (ticket + metadata).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CcacheCredential {
|
||||
/// Client principal.
|
||||
pub client: CcachePrincipal,
|
||||
/// Server (service) principal.
|
||||
pub server: CcachePrincipal,
|
||||
/// Session key encryption type.
|
||||
pub key_etype: u16,
|
||||
/// Session key bytes.
|
||||
pub key_data: Vec<u8>,
|
||||
/// Time the ticket was issued (Unix timestamp).
|
||||
pub authtime: u32,
|
||||
/// Time the ticket becomes valid (Unix timestamp).
|
||||
pub starttime: u32,
|
||||
/// Time the ticket expires (Unix timestamp).
|
||||
pub endtime: u32,
|
||||
/// Time the ticket's renewable lifetime expires (Unix timestamp).
|
||||
pub renew_till: u32,
|
||||
/// Raw ticket bytes (DER-encoded Kerberos Ticket).
|
||||
pub ticket: Vec<u8>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read and parse a ccache file from a filesystem path.
|
||||
///
|
||||
/// Reads `$KRB5CCNAME` if `path` is `None`, falling back to
|
||||
/// `/tmp/krb5cc_<uid>` on Unix.
|
||||
pub fn load_ccache(path: Option<&std::path::Path>) -> Result<CCache> {
|
||||
let path = match path {
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => {
|
||||
if let Ok(env_path) = std::env::var("KRB5CCNAME") {
|
||||
// Strip "FILE:" prefix if present.
|
||||
let p = env_path.strip_prefix("FILE:").unwrap_or(&env_path);
|
||||
std::path::PathBuf::from(p)
|
||||
} else {
|
||||
// Default: /tmp/krb5cc_<uid>
|
||||
return Err(Error::invalid_data(
|
||||
"ccache: no path specified and $KRB5CCNAME not set",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let data = std::fs::read(&path).map_err(|e| {
|
||||
Error::invalid_data(format!("ccache: failed to read {}: {e}", path.display()))
|
||||
})?;
|
||||
|
||||
parse_ccache(&data)
|
||||
}
|
||||
|
||||
/// Parse a ccache file from raw bytes.
|
||||
pub fn parse_ccache(data: &[u8]) -> Result<CCache> {
|
||||
let mut pos = 0;
|
||||
|
||||
// Version: 2 bytes, big-endian. We support 0x0503 (v3) and 0x0504 (v4).
|
||||
if data.len() < 2 {
|
||||
return Err(Error::invalid_data("ccache: file too short for version"));
|
||||
}
|
||||
let version = read_u16(data, &mut pos)?;
|
||||
if version != 0x0503 && version != 0x0504 {
|
||||
return Err(Error::invalid_data(format!(
|
||||
"ccache: unsupported version 0x{version:04x} (expected 0x0503 or 0x0504)"
|
||||
)));
|
||||
}
|
||||
|
||||
// V4 has a header section after the version.
|
||||
if version == 0x0504 {
|
||||
let header_len = read_u16(data, &mut pos)? as usize;
|
||||
if pos + header_len > data.len() {
|
||||
return Err(Error::invalid_data(
|
||||
"ccache: header extends past end of file",
|
||||
));
|
||||
}
|
||||
// Skip header tags (we don't need them).
|
||||
pos += header_len;
|
||||
}
|
||||
|
||||
// Default principal.
|
||||
let default_principal = read_principal(data, &mut pos)?;
|
||||
|
||||
// Credentials: read until EOF.
|
||||
let mut credentials = Vec::new();
|
||||
while pos < data.len() {
|
||||
match read_credential(data, &mut pos) {
|
||||
Ok(cred) => credentials.push(cred),
|
||||
Err(_) => break, // Treat parse errors at the end as EOF.
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"ccache: parsed v{}, principal={}@{}, {} credentials",
|
||||
version & 0xFF,
|
||||
default_principal.components.join("/"),
|
||||
default_principal.realm,
|
||||
credentials.len()
|
||||
);
|
||||
|
||||
Ok(CCache {
|
||||
version,
|
||||
default_principal,
|
||||
credentials,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl CCache {
|
||||
/// Find a cached service ticket for the given SPN and realm.
|
||||
///
|
||||
/// Looks for a credential where the server principal matches
|
||||
/// `service/hostname@realm` (case-insensitive hostname comparison).
|
||||
pub fn find_service_ticket(
|
||||
&self,
|
||||
service: &str,
|
||||
hostname: &str,
|
||||
realm: &str,
|
||||
) -> Option<&CcacheCredential> {
|
||||
self.credentials.iter().find(|c| {
|
||||
c.server.realm.eq_ignore_ascii_case(realm)
|
||||
&& c.server.components.len() == 2
|
||||
&& c.server.components[0].eq_ignore_ascii_case(service)
|
||||
&& c.server.components[1].eq_ignore_ascii_case(hostname)
|
||||
})
|
||||
}
|
||||
|
||||
/// Find a cached TGT for the given realm.
|
||||
///
|
||||
/// Looks for a credential where the server principal is `krbtgt/REALM@REALM`.
|
||||
pub fn find_tgt(&self, realm: &str) -> Option<&CcacheCredential> {
|
||||
self.credentials.iter().find(|c| {
|
||||
c.server.realm.eq_ignore_ascii_case(realm)
|
||||
&& c.server.components.len() == 2
|
||||
&& c.server.components[0] == "krbtgt"
|
||||
&& c.server.components[1].eq_ignore_ascii_case(realm)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Binary reading helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn read_u8(data: &[u8], pos: &mut usize) -> Result<u8> {
|
||||
if *pos >= data.len() {
|
||||
return Err(Error::invalid_data("ccache: unexpected end of data"));
|
||||
}
|
||||
let val = data[*pos];
|
||||
*pos += 1;
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
fn read_u16(data: &[u8], pos: &mut usize) -> Result<u16> {
|
||||
if *pos + 2 > data.len() {
|
||||
return Err(Error::invalid_data("ccache: unexpected end of data"));
|
||||
}
|
||||
let val = u16::from_be_bytes([data[*pos], data[*pos + 1]]);
|
||||
*pos += 2;
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
fn read_u32(data: &[u8], pos: &mut usize) -> Result<u32> {
|
||||
if *pos + 4 > data.len() {
|
||||
return Err(Error::invalid_data("ccache: unexpected end of data"));
|
||||
}
|
||||
let val = u32::from_be_bytes([data[*pos], data[*pos + 1], data[*pos + 2], data[*pos + 3]]);
|
||||
*pos += 4;
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
fn read_bytes(data: &[u8], pos: &mut usize, len: usize) -> Result<Vec<u8>> {
|
||||
if *pos + len > data.len() {
|
||||
return Err(Error::invalid_data("ccache: unexpected end of data"));
|
||||
}
|
||||
let val = data[*pos..*pos + len].to_vec();
|
||||
*pos += len;
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
fn read_string(data: &[u8], pos: &mut usize) -> Result<String> {
|
||||
let len = read_u32(data, pos)? as usize;
|
||||
let bytes = read_bytes(data, pos, len)?;
|
||||
String::from_utf8(bytes).map_err(|_| Error::invalid_data("ccache: invalid UTF-8 in string"))
|
||||
}
|
||||
|
||||
fn read_principal(data: &[u8], pos: &mut usize) -> Result<CcachePrincipal> {
|
||||
let name_type = read_u32(data, pos)?;
|
||||
let num_components = read_u32(data, pos)?;
|
||||
let realm = read_string(data, pos)?;
|
||||
let mut components = Vec::with_capacity(num_components as usize);
|
||||
for _ in 0..num_components {
|
||||
components.push(read_string(data, pos)?);
|
||||
}
|
||||
Ok(CcachePrincipal {
|
||||
name_type,
|
||||
realm,
|
||||
components,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_keyblock(data: &[u8], pos: &mut usize) -> Result<(u16, Vec<u8>)> {
|
||||
let enctype = read_u16(data, pos)?;
|
||||
let key_len = read_u32(data, pos)? as usize;
|
||||
let key_data = read_bytes(data, pos, key_len)?;
|
||||
Ok((enctype, key_data))
|
||||
}
|
||||
|
||||
fn read_credential(data: &[u8], pos: &mut usize) -> Result<CcacheCredential> {
|
||||
let client = read_principal(data, pos)?;
|
||||
let server = read_principal(data, pos)?;
|
||||
let (key_etype, key_data) = read_keyblock(data, pos)?;
|
||||
let authtime = read_u32(data, pos)?;
|
||||
let starttime = read_u32(data, pos)?;
|
||||
let endtime = read_u32(data, pos)?;
|
||||
let renew_till = read_u32(data, pos)?;
|
||||
let _is_skey = read_u8(data, pos)?;
|
||||
let _ticket_flags = read_u32(data, pos)?;
|
||||
|
||||
// Addresses (count + entries).
|
||||
let addr_count = read_u32(data, pos)?;
|
||||
for _ in 0..addr_count {
|
||||
let _addr_type = read_u16(data, pos)?;
|
||||
let addr_len = read_u32(data, pos)? as usize;
|
||||
*pos += addr_len; // skip address data
|
||||
}
|
||||
|
||||
// Auth data (count + entries).
|
||||
let authdata_count = read_u32(data, pos)?;
|
||||
for _ in 0..authdata_count {
|
||||
let _ad_type = read_u16(data, pos)?;
|
||||
let ad_len = read_u32(data, pos)? as usize;
|
||||
*pos += ad_len; // skip authdata
|
||||
}
|
||||
|
||||
// Ticket.
|
||||
let ticket_len = read_u32(data, pos)? as usize;
|
||||
let ticket = read_bytes(data, pos, ticket_len)?;
|
||||
|
||||
// Second ticket.
|
||||
let second_ticket_len = read_u32(data, pos)? as usize;
|
||||
let _second_ticket = read_bytes(data, pos, second_ticket_len)?;
|
||||
|
||||
Ok(CcacheCredential {
|
||||
client,
|
||||
server,
|
||||
key_etype,
|
||||
key_data,
|
||||
authtime,
|
||||
starttime,
|
||||
endtime,
|
||||
renew_till,
|
||||
ticket,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_v4_ccache_from_fixture() {
|
||||
let data = include_bytes!("../../../tests/fixtures/test.ccache");
|
||||
let ccache = parse_ccache(data).expect("failed to parse v4 ccache");
|
||||
|
||||
assert_eq!(ccache.version, 0x0504);
|
||||
assert_eq!(ccache.default_principal.realm, "TEST.LOCAL");
|
||||
assert_eq!(ccache.default_principal.components, vec!["smbtest"]);
|
||||
assert_eq!(ccache.credentials.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_v3_ccache_from_fixture() {
|
||||
let data = include_bytes!("../../../tests/fixtures/test_v3.ccache");
|
||||
let ccache = parse_ccache(data).expect("failed to parse v3 ccache");
|
||||
|
||||
assert_eq!(ccache.version, 0x0503);
|
||||
assert_eq!(ccache.default_principal.realm, "EXAMPLE.COM");
|
||||
assert_eq!(ccache.default_principal.components, vec!["user"]);
|
||||
assert_eq!(ccache.credentials.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tgt_credential_has_correct_fields() {
|
||||
let data = include_bytes!("../../../tests/fixtures/test.ccache");
|
||||
let ccache = parse_ccache(data).unwrap();
|
||||
|
||||
let tgt = &ccache.credentials[0];
|
||||
assert_eq!(tgt.client.realm, "TEST.LOCAL");
|
||||
assert_eq!(tgt.client.components, vec!["smbtest"]);
|
||||
assert_eq!(tgt.server.realm, "TEST.LOCAL");
|
||||
assert_eq!(tgt.server.components, vec!["krbtgt", "TEST.LOCAL"]);
|
||||
assert_eq!(tgt.key_etype, 23); // RC4-HMAC
|
||||
assert_eq!(tgt.key_data.len(), 16);
|
||||
assert_eq!(tgt.authtime, 1744100000);
|
||||
assert_eq!(tgt.endtime, 1744200000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_ticket_has_correct_fields() {
|
||||
let data = include_bytes!("../../../tests/fixtures/test.ccache");
|
||||
let ccache = parse_ccache(data).unwrap();
|
||||
|
||||
let svc = &ccache.credentials[1];
|
||||
assert_eq!(svc.server.components, vec!["cifs", "server.test.local"]);
|
||||
assert_eq!(svc.key_etype, 23);
|
||||
assert_eq!(svc.key_data, (16u8..32).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_tgt_by_realm() {
|
||||
let data = include_bytes!("../../../tests/fixtures/test.ccache");
|
||||
let ccache = parse_ccache(data).unwrap();
|
||||
|
||||
let tgt = ccache.find_tgt("TEST.LOCAL");
|
||||
assert!(tgt.is_some());
|
||||
assert_eq!(tgt.unwrap().server.components[0], "krbtgt");
|
||||
|
||||
assert!(ccache.find_tgt("OTHER.REALM").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_service_ticket_by_spn() {
|
||||
let data = include_bytes!("../../../tests/fixtures/test.ccache");
|
||||
let ccache = parse_ccache(data).unwrap();
|
||||
|
||||
let svc = ccache.find_service_ticket("cifs", "server.test.local", "TEST.LOCAL");
|
||||
assert!(svc.is_some());
|
||||
assert_eq!(svc.unwrap().key_data, (16u8..32).collect::<Vec<_>>());
|
||||
|
||||
// Case-insensitive hostname.
|
||||
assert!(ccache
|
||||
.find_service_ticket("cifs", "SERVER.TEST.LOCAL", "TEST.LOCAL")
|
||||
.is_some());
|
||||
|
||||
// Wrong hostname.
|
||||
assert!(ccache
|
||||
.find_service_ticket("cifs", "other.test.local", "TEST.LOCAL")
|
||||
.is_none());
|
||||
|
||||
// Wrong service.
|
||||
assert!(ccache
|
||||
.find_service_ticket("ldap", "server.test.local", "TEST.LOCAL")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_tgt_case_insensitive() {
|
||||
let data = include_bytes!("../../../tests/fixtures/test.ccache");
|
||||
let ccache = parse_ccache(data).unwrap();
|
||||
|
||||
assert!(ccache.find_tgt("test.local").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_ccache_tgt_has_aes256_key() {
|
||||
let data = include_bytes!("../../../tests/fixtures/test_v3.ccache");
|
||||
let ccache = parse_ccache(data).unwrap();
|
||||
|
||||
let tgt = ccache.find_tgt("EXAMPLE.COM").unwrap();
|
||||
assert_eq!(tgt.key_etype, 18); // AES-256
|
||||
assert_eq!(tgt.key_data.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_unsupported_version() {
|
||||
let data = [0x05, 0x02]; // v2
|
||||
let result = parse_ccache(&data);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unsupported version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_truncated_file() {
|
||||
let result = parse_ccache(&[0x05]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_credentials_list() {
|
||||
// A valid ccache with just a version + principal + no credentials
|
||||
let mut data = vec![0x05, 0x04, 0x00, 0x00]; // v4, no header
|
||||
// Principal: type=1, components=1, realm="R", component="u"
|
||||
data.extend_from_slice(&[0, 0, 0, 1]); // name_type
|
||||
data.extend_from_slice(&[0, 0, 0, 1]); // num_components
|
||||
data.extend_from_slice(&[0, 0, 0, 1]); // realm length
|
||||
data.push(b'R');
|
||||
data.extend_from_slice(&[0, 0, 0, 1]); // component length
|
||||
data.push(b'u');
|
||||
|
||||
let ccache = parse_ccache(&data).unwrap();
|
||||
assert_eq!(ccache.credentials.len(), 0);
|
||||
assert_eq!(ccache.default_principal.realm, "R");
|
||||
assert_eq!(ccache.default_principal.components, vec!["u"]);
|
||||
}
|
||||
}
|
||||
1329
vendor/smb2/src/auth/kerberos/crypto.rs
vendored
Normal file
1329
vendor/smb2/src/auth/kerberos/crypto.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
890
vendor/smb2/src/auth/kerberos/kdc.rs
vendored
Normal file
890
vendor/smb2/src/auth/kerberos/kdc.rs
vendored
Normal file
@@ -0,0 +1,890 @@
|
||||
//! KDC (Key Distribution Center) transport client.
|
||||
//!
|
||||
//! Sends AS-REQ and TGS-REQ messages to a Kerberos KDC on port 88.
|
||||
//! Tries UDP first (no framing), falls back to TCP (4-byte big-endian
|
||||
//! length prefix) when the response indicates KRB_ERR_RESPONSE_TOO_BIG
|
||||
//! (error code 52).
|
||||
//!
|
||||
//! Transport details per RFC 4120 section 7.2 and MS-KILE section 2.1:
|
||||
//! - UDP: raw DER bytes, no length prefix, max 65535 bytes
|
||||
//! - TCP: 4-byte big-endian length prefix, then DER bytes
|
||||
//! - Retry: up to 3 attempts with exponential backoff (1s, 2s, 4s)
|
||||
//!
|
||||
//! The functions here are transport-only: they send raw bytes and return
|
||||
//! raw bytes. No ASN.1 parsing beyond detecting error code 52 in the
|
||||
//! UDP-to-TCP fallback path.
|
||||
|
||||
use log::{debug, trace, warn};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, UdpSocket};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Default Kerberos port (RFC 4120).
|
||||
const KERBEROS_PORT: u16 = 88;
|
||||
|
||||
/// Maximum UDP receive buffer size.
|
||||
const UDP_MAX_SIZE: usize = 65535;
|
||||
|
||||
/// KRB_ERR_RESPONSE_TOO_BIG error code (RFC 4120 section 7.2.1).
|
||||
const KRB_ERR_RESPONSE_TOO_BIG: u32 = 52;
|
||||
|
||||
/// Maximum TCP frame size we accept (1 MB, generous for Kerberos).
|
||||
const MAX_KDC_FRAME_SIZE: usize = 1024 * 1024;
|
||||
|
||||
/// Number of retry attempts per transport.
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
|
||||
/// Base retry delay (doubles each attempt).
|
||||
const RETRY_BASE_DELAY: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Configuration for connecting to a KDC.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KdcConfig {
|
||||
/// KDC address (host:port or just host, defaults to port 88).
|
||||
pub address: String,
|
||||
/// Connection/request timeout.
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
/// Resolve the KDC address to include a port if not specified.
|
||||
fn resolve_address(address: &str) -> String {
|
||||
if address.contains(':') {
|
||||
address.to_string()
|
||||
} else {
|
||||
format!("{}:{}", address, KERBEROS_PORT)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a Kerberos message to the KDC and receive the response.
|
||||
///
|
||||
/// Tries UDP first. If the response indicates the message was too
|
||||
/// large for UDP (KRB_ERR_RESPONSE_TOO_BIG), retries with TCP.
|
||||
///
|
||||
/// UDP framing: raw DER bytes, no length prefix.
|
||||
/// TCP framing: 4-byte big-endian length prefix, then DER bytes.
|
||||
pub async fn send_to_kdc(config: &KdcConfig, message: &[u8]) -> Result<Vec<u8>> {
|
||||
let addr = resolve_address(&config.address);
|
||||
debug!("kdc: sending {} bytes to {}", message.len(), addr);
|
||||
|
||||
// Try UDP first.
|
||||
match send_udp(&addr, message, config.timeout).await {
|
||||
Ok(response) => {
|
||||
if is_response_too_big(&response) {
|
||||
debug!("kdc: got KRB_ERR_RESPONSE_TOO_BIG, retrying with TCP");
|
||||
send_tcp(&addr, message, config.timeout).await
|
||||
} else {
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("kdc: UDP failed ({}), falling back to TCP", e);
|
||||
send_tcp(&addr, message, config.timeout).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a Kerberos message via UDP.
|
||||
async fn send_udp(addr: &str, message: &[u8], timeout: Duration) -> Result<Vec<u8>> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await.map_err(Error::Io)?;
|
||||
|
||||
let mut last_err = None;
|
||||
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
if attempt > 0 {
|
||||
let delay = RETRY_BASE_DELAY * 2u32.pow(attempt - 1);
|
||||
debug!("kdc: UDP retry {} after {:?}", attempt, delay);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
|
||||
// Send the raw DER bytes (no framing for UDP).
|
||||
match tokio::time::timeout(timeout, socket.send_to(message, addr)).await {
|
||||
Ok(Ok(n)) => {
|
||||
trace!("kdc: UDP sent {} bytes", n);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
last_err = Some(Error::Io(e));
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
last_err = Some(Error::Timeout);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Receive the response.
|
||||
let mut buf = vec![0u8; UDP_MAX_SIZE];
|
||||
match tokio::time::timeout(timeout, socket.recv_from(&mut buf)).await {
|
||||
Ok(Ok((n, _src))) => {
|
||||
trace!("kdc: UDP received {} bytes", n);
|
||||
buf.truncate(n);
|
||||
return Ok(buf);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
last_err = Some(Error::Io(e));
|
||||
}
|
||||
Err(_) => {
|
||||
last_err = Some(Error::Timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or(Error::Timeout))
|
||||
}
|
||||
|
||||
/// Send a Kerberos message via TCP.
|
||||
async fn send_tcp(addr: &str, message: &[u8], timeout: Duration) -> Result<Vec<u8>> {
|
||||
let mut last_err = None;
|
||||
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
if attempt > 0 {
|
||||
let delay = RETRY_BASE_DELAY * 2u32.pow(attempt - 1);
|
||||
debug!("kdc: TCP retry {} after {:?}", attempt, delay);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
|
||||
match send_tcp_once(addr, message, timeout).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or(Error::Timeout))
|
||||
}
|
||||
|
||||
/// Single TCP send/receive attempt.
|
||||
async fn send_tcp_once(addr: &str, message: &[u8], timeout: Duration) -> Result<Vec<u8>> {
|
||||
// Connect with timeout.
|
||||
let mut stream = tokio::time::timeout(timeout, TcpStream::connect(addr))
|
||||
.await
|
||||
.map_err(|_| Error::Timeout)?
|
||||
.map_err(Error::Io)?;
|
||||
|
||||
// Disable Nagle for lower latency.
|
||||
stream.set_nodelay(true).map_err(Error::Io)?;
|
||||
|
||||
// Send: 4-byte big-endian length prefix + DER bytes.
|
||||
let len = message.len() as u32;
|
||||
let len_bytes = len.to_be_bytes();
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
stream.write_all(&len_bytes).await.map_err(Error::Io)?;
|
||||
stream.write_all(message).await.map_err(Error::Io)?;
|
||||
stream.flush().await.map_err(Error::Io)?;
|
||||
trace!("kdc: TCP sent {} bytes", message.len());
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Timeout)??;
|
||||
|
||||
// Receive: 4-byte big-endian length prefix.
|
||||
let mut len_buf = [0u8; 4];
|
||||
tokio::time::timeout(timeout, stream.read_exact(&mut len_buf))
|
||||
.await
|
||||
.map_err(|_| Error::Timeout)?
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof {
|
||||
Error::Disconnected
|
||||
} else {
|
||||
Error::Io(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
let resp_len = u32::from_be_bytes(len_buf) as usize;
|
||||
if resp_len > MAX_KDC_FRAME_SIZE {
|
||||
return Err(Error::invalid_data(format!(
|
||||
"KDC TCP response length {} exceeds maximum {}",
|
||||
resp_len, MAX_KDC_FRAME_SIZE
|
||||
)));
|
||||
}
|
||||
|
||||
// Read the response body.
|
||||
let mut buf = vec![0u8; resp_len];
|
||||
tokio::time::timeout(timeout, stream.read_exact(&mut buf))
|
||||
.await
|
||||
.map_err(|_| Error::Timeout)?
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof {
|
||||
Error::Disconnected
|
||||
} else {
|
||||
Error::Io(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
trace!("kdc: TCP received {} bytes", resp_len);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Detect KRB_ERR_RESPONSE_TOO_BIG (error code 52) in a KRB-ERROR response.
|
||||
///
|
||||
/// KRB-ERROR is APPLICATION [30] (tag 0x7e). We parse just enough DER
|
||||
/// to extract the error-code field (context tag [6]) without a full
|
||||
/// ASN.1 parser.
|
||||
fn is_response_too_big(response: &[u8]) -> bool {
|
||||
// KRB-ERROR starts with APPLICATION [30] = 0x7e.
|
||||
if response.is_empty() || response[0] != 0x7e {
|
||||
return false;
|
||||
}
|
||||
|
||||
match extract_krb_error_code(response) {
|
||||
Some(code) => code == KRB_ERR_RESPONSE_TOO_BIG,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the error-code from a KRB-ERROR message.
|
||||
///
|
||||
/// KRB-ERROR structure (simplified DER):
|
||||
/// ```text
|
||||
/// APPLICATION [30] {
|
||||
/// SEQUENCE {
|
||||
/// [0] pvno INTEGER,
|
||||
/// [1] msg-type INTEGER,
|
||||
/// [2] ctime (optional),
|
||||
/// [3] cusec (optional),
|
||||
/// [4] stime,
|
||||
/// [5] susec,
|
||||
/// [6] error-code INTEGER, <-- we want this
|
||||
/// ...
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
fn extract_krb_error_code(data: &[u8]) -> Option<u32> {
|
||||
let mut pos = 0;
|
||||
|
||||
// Skip APPLICATION [30] tag.
|
||||
if pos >= data.len() || data[pos] != 0x7e {
|
||||
return None;
|
||||
}
|
||||
pos += 1;
|
||||
pos = skip_der_length(data, pos)?;
|
||||
|
||||
// Skip SEQUENCE tag (0x30).
|
||||
if pos >= data.len() || data[pos] != 0x30 {
|
||||
return None;
|
||||
}
|
||||
pos += 1;
|
||||
pos = skip_der_length(data, pos)?;
|
||||
|
||||
// Now iterate through context-tagged fields until we find [6].
|
||||
loop {
|
||||
if pos >= data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tag = data[pos];
|
||||
// Context tags are 0xa0..0xbf for constructed.
|
||||
if tag & 0xe0 != 0xa0 {
|
||||
return None;
|
||||
}
|
||||
let tag_num = tag & 0x1f;
|
||||
pos += 1;
|
||||
|
||||
let (field_len, new_pos) = read_der_length(data, pos)?;
|
||||
let field_end = new_pos + field_len;
|
||||
|
||||
if tag_num == 6 {
|
||||
// This field contains an INTEGER with the error code.
|
||||
return parse_der_integer(data, new_pos);
|
||||
}
|
||||
|
||||
pos = field_end;
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip a DER length field and return the position after it.
|
||||
fn skip_der_length(data: &[u8], pos: usize) -> Option<usize> {
|
||||
let (_len, new_pos) = read_der_length(data, pos)?;
|
||||
Some(new_pos)
|
||||
}
|
||||
|
||||
/// Read a DER length field, returning (length, position_after_length).
|
||||
fn read_der_length(data: &[u8], pos: usize) -> Option<(usize, usize)> {
|
||||
if pos >= data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first = data[pos];
|
||||
match first.cmp(&0x80) {
|
||||
std::cmp::Ordering::Less => {
|
||||
// Short form: length is the byte itself.
|
||||
Some((first as usize, pos + 1))
|
||||
}
|
||||
std::cmp::Ordering::Equal => {
|
||||
// Indefinite length, not used in DER.
|
||||
None
|
||||
}
|
||||
std::cmp::Ordering::Greater => {
|
||||
// Long form: first byte & 0x7f = number of subsequent length bytes.
|
||||
let num_bytes = (first & 0x7f) as usize;
|
||||
if num_bytes > 4 || pos + 1 + num_bytes > data.len() {
|
||||
return None;
|
||||
}
|
||||
let mut length: usize = 0;
|
||||
for i in 0..num_bytes {
|
||||
length = (length << 8) | (data[pos + 1 + i] as usize);
|
||||
}
|
||||
Some((length, pos + 1 + num_bytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a DER INTEGER at the given position, returning its value as u32.
|
||||
fn parse_der_integer(data: &[u8], pos: usize) -> Option<u32> {
|
||||
if pos >= data.len() || data[pos] != 0x02 {
|
||||
return None;
|
||||
}
|
||||
let (len, val_pos) = read_der_length(data, pos + 1)?;
|
||||
if val_pos + len > data.len() || len == 0 || len > 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut value: u32 = 0;
|
||||
for i in 0..len {
|
||||
value = (value << 8) | (data[val_pos + i] as u32);
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
|
||||
/// Discover KDC addresses for a realm via DNS SRV records.
|
||||
///
|
||||
/// Looks up `_kerberos._udp.{realm}` and `_kerberos._tcp.{realm}`.
|
||||
/// Returns addresses sorted by priority.
|
||||
///
|
||||
/// For now, this is a placeholder -- initial implementation uses
|
||||
/// the hardcoded address from KdcConfig. DNS SRV discovery will
|
||||
/// be added in a future version.
|
||||
pub async fn discover_kdc(_realm: &str) -> Vec<String> {
|
||||
// Placeholder: DNS SRV lookup not yet implemented.
|
||||
// Callers should use KdcConfig.address directly.
|
||||
debug!("kdc: DNS SRV discovery not yet implemented, returning empty list");
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
// ── DER parsing tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn read_der_length_short_form() {
|
||||
assert_eq!(read_der_length(&[0x05], 0), Some((5, 1)));
|
||||
assert_eq!(read_der_length(&[0x7f], 0), Some((127, 1)));
|
||||
assert_eq!(read_der_length(&[0x00], 0), Some((0, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_der_length_long_form_one_byte() {
|
||||
// 0x81, 0x80 = 128 bytes
|
||||
assert_eq!(read_der_length(&[0x81, 0x80], 0), Some((128, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_der_length_long_form_two_bytes() {
|
||||
// 0x82, 0x01, 0x00 = 256 bytes
|
||||
assert_eq!(read_der_length(&[0x82, 0x01, 0x00], 0), Some((256, 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_der_length_indefinite_returns_none() {
|
||||
assert_eq!(read_der_length(&[0x80], 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_der_length_truncated_returns_none() {
|
||||
// Says 2 length bytes follow but only 1 is present.
|
||||
assert_eq!(read_der_length(&[0x82, 0x01], 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_der_integer_single_byte() {
|
||||
// INTEGER tag 0x02, length 1, value 52.
|
||||
assert_eq!(parse_der_integer(&[0x02, 0x01, 0x34], 0), Some(52));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_der_integer_two_bytes() {
|
||||
// INTEGER tag 0x02, length 2, value 0x0100 = 256.
|
||||
assert_eq!(parse_der_integer(&[0x02, 0x02, 0x01, 0x00], 0), Some(256));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_der_integer_not_integer_tag() {
|
||||
assert_eq!(parse_der_integer(&[0x03, 0x01, 0x34], 0), None);
|
||||
}
|
||||
|
||||
// ── KRB-ERROR detection tests ──────────────────────────────────
|
||||
|
||||
/// Build a minimal KRB-ERROR with the given error code.
|
||||
///
|
||||
/// This constructs a valid DER-encoded KRB-ERROR with fields:
|
||||
/// [0] pvno = 5, [1] msg-type = 30, [4] stime, [5] susec = 0,
|
||||
/// [6] error-code = the given code.
|
||||
fn build_krb_error(error_code: u32) -> Vec<u8> {
|
||||
// Helper: wrap value in context tag.
|
||||
fn context_tag(tag_num: u8, contents: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![0xa0 | tag_num];
|
||||
push_der_length(&mut out, contents.len());
|
||||
out.extend_from_slice(contents);
|
||||
out
|
||||
}
|
||||
|
||||
// Helper: encode a DER INTEGER.
|
||||
fn der_integer(value: u32) -> Vec<u8> {
|
||||
// Encode as minimal bytes.
|
||||
let bytes = if value == 0 {
|
||||
vec![0x00]
|
||||
} else if value < 0x80 {
|
||||
vec![value as u8]
|
||||
} else if value < 0x8000 {
|
||||
vec![(value >> 8) as u8, (value & 0xff) as u8]
|
||||
} else if value < 0x800000 {
|
||||
vec![
|
||||
(value >> 16) as u8,
|
||||
(value >> 8) as u8,
|
||||
(value & 0xff) as u8,
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
(value >> 24) as u8,
|
||||
(value >> 16) as u8,
|
||||
(value >> 8) as u8,
|
||||
(value & 0xff) as u8,
|
||||
]
|
||||
};
|
||||
let mut out = vec![0x02];
|
||||
push_der_length(&mut out, bytes.len());
|
||||
out.extend_from_slice(&bytes);
|
||||
out
|
||||
}
|
||||
|
||||
fn push_der_length(out: &mut Vec<u8>, len: usize) {
|
||||
if len < 0x80 {
|
||||
out.push(len as u8);
|
||||
} else if len < 0x100 {
|
||||
out.push(0x81);
|
||||
out.push(len as u8);
|
||||
} else {
|
||||
out.push(0x82);
|
||||
out.push((len >> 8) as u8);
|
||||
out.push((len & 0xff) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the SEQUENCE contents.
|
||||
let pvno = context_tag(0, &der_integer(5));
|
||||
let msg_type = context_tag(1, &der_integer(30));
|
||||
// Skip [2] ctime and [3] cusec (optional).
|
||||
// [4] stime: GeneralizedTime "20250101000000Z"
|
||||
let stime_val = b"20250101000000Z";
|
||||
let mut stime_der = vec![0x18]; // GeneralizedTime tag
|
||||
push_der_length(&mut stime_der, stime_val.len());
|
||||
stime_der.extend_from_slice(stime_val);
|
||||
let stime = context_tag(4, &stime_der);
|
||||
let susec = context_tag(5, &der_integer(0));
|
||||
let error_code_field = context_tag(6, &der_integer(error_code));
|
||||
|
||||
let mut seq_contents = Vec::new();
|
||||
seq_contents.extend_from_slice(&pvno);
|
||||
seq_contents.extend_from_slice(&msg_type);
|
||||
seq_contents.extend_from_slice(&stime);
|
||||
seq_contents.extend_from_slice(&susec);
|
||||
seq_contents.extend_from_slice(&error_code_field);
|
||||
|
||||
// Wrap in SEQUENCE.
|
||||
let mut seq = vec![0x30];
|
||||
push_der_length(&mut seq, seq_contents.len());
|
||||
seq.extend_from_slice(&seq_contents);
|
||||
|
||||
// Wrap in APPLICATION [30].
|
||||
let mut msg = vec![0x7e];
|
||||
push_der_length(&mut msg, seq.len());
|
||||
msg.extend_from_slice(&seq);
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_response_too_big_detects_error_52() {
|
||||
let error = build_krb_error(KRB_ERR_RESPONSE_TOO_BIG);
|
||||
assert!(is_response_too_big(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_response_too_big_ignores_other_errors() {
|
||||
// Error code 6 = KDC_ERR_C_PRINCIPAL_UNKNOWN
|
||||
let error = build_krb_error(6);
|
||||
assert!(!is_response_too_big(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_response_too_big_ignores_non_error_messages() {
|
||||
// AS-REP starts with APPLICATION [11] = 0x6b
|
||||
assert!(!is_response_too_big(&[0x6b, 0x03, 0x30, 0x01, 0x00]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_response_too_big_handles_empty_response() {
|
||||
assert!(!is_response_too_big(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_response_too_big_handles_truncated_response() {
|
||||
// Just the APPLICATION tag and nothing else.
|
||||
assert!(!is_response_too_big(&[0x7e]));
|
||||
assert!(!is_response_too_big(&[0x7e, 0x00]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_code_from_valid_krb_error() {
|
||||
let error = build_krb_error(25);
|
||||
assert_eq!(extract_krb_error_code(&error), Some(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_error_code_returns_none_for_non_error() {
|
||||
assert_eq!(
|
||||
extract_krb_error_code(&[0x6b, 0x03, 0x30, 0x01, 0x00]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
// ── Address resolution tests ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_address_adds_default_port() {
|
||||
assert_eq!(resolve_address("kdc.example.com"), "kdc.example.com:88");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_address_preserves_explicit_port() {
|
||||
assert_eq!(
|
||||
resolve_address("kdc.example.com:8888"),
|
||||
"kdc.example.com:8888"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_address_ip_no_port() {
|
||||
assert_eq!(resolve_address("10.0.0.1"), "10.0.0.1:88");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_address_ip_with_port() {
|
||||
assert_eq!(resolve_address("10.0.0.1:88"), "10.0.0.1:88");
|
||||
}
|
||||
|
||||
// ── UDP transport tests ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn udp_send_receive() {
|
||||
// Set up a mock KDC that echoes the request back.
|
||||
let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; UDP_MAX_SIZE];
|
||||
let (n, src) = server.recv_from(&mut buf).await.unwrap();
|
||||
// Echo back the message.
|
||||
server.send_to(&buf[..n], src).await.unwrap();
|
||||
});
|
||||
|
||||
let message = b"test-kerberos-message";
|
||||
let result = send_udp(&server_addr.to_string(), message, Duration::from_secs(5)).await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"UDP send/receive failed: {:?}",
|
||||
result.err()
|
||||
);
|
||||
assert_eq!(result.unwrap(), message);
|
||||
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn udp_timeout_on_no_response() {
|
||||
// Bind a server socket but never read from it.
|
||||
let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
|
||||
// Use very short timeout and only 1 retry attempt to keep test fast.
|
||||
// We can't change MAX_RETRIES, but we use a very short timeout so
|
||||
// all 3 retries finish quickly.
|
||||
let result = send_udp(
|
||||
&server_addr.to_string(),
|
||||
b"hello",
|
||||
Duration::from_millis(50),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
matches!(result.as_ref().unwrap_err(), Error::Timeout),
|
||||
"expected Timeout, got: {:?}",
|
||||
result.unwrap_err()
|
||||
);
|
||||
|
||||
drop(server);
|
||||
}
|
||||
|
||||
// ── TCP transport tests ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tcp_send_receive() {
|
||||
// Set up a mock KDC that reads a length-prefixed message and
|
||||
// sends back a length-prefixed response.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
|
||||
// Read 4-byte length prefix.
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).await.unwrap();
|
||||
let msg_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
// Read the message body.
|
||||
let mut msg = vec![0u8; msg_len];
|
||||
stream.read_exact(&mut msg).await.unwrap();
|
||||
|
||||
// Echo back with length prefix.
|
||||
let response = b"kdc-response";
|
||||
let resp_len = (response.len() as u32).to_be_bytes();
|
||||
stream.write_all(&resp_len).await.unwrap();
|
||||
stream.write_all(response).await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
});
|
||||
|
||||
let result = send_tcp(&addr.to_string(), b"test-request", Duration::from_secs(5)).await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"TCP send/receive failed: {:?}",
|
||||
result.err()
|
||||
);
|
||||
assert_eq!(result.unwrap(), b"kdc-response");
|
||||
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tcp_timeout_on_no_response() {
|
||||
// Set up a server that accepts but never responds.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
// Hold the connection open but never respond.
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
drop(stream);
|
||||
});
|
||||
|
||||
let result = send_tcp_once(&addr.to_string(), b"hello", Duration::from_millis(100)).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, Error::Timeout),
|
||||
"expected Timeout, got: {err}"
|
||||
);
|
||||
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tcp_truncated_response() {
|
||||
// Server sends a length prefix saying 100 bytes, then disconnects.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
|
||||
// Read the request (don't care about contents).
|
||||
let mut len_buf = [0u8; 4];
|
||||
let _ = stream.read_exact(&mut len_buf).await;
|
||||
let msg_len = u32::from_be_bytes(len_buf) as usize;
|
||||
let mut discard = vec![0u8; msg_len];
|
||||
let _ = stream.read_exact(&mut discard).await;
|
||||
|
||||
// Send response with length 100 but only 5 bytes of data, then close.
|
||||
let resp_len = 100u32.to_be_bytes();
|
||||
stream.write_all(&resp_len).await.unwrap();
|
||||
stream
|
||||
.write_all(&[0x01, 0x02, 0x03, 0x04, 0x05])
|
||||
.await
|
||||
.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
});
|
||||
|
||||
let result = send_tcp_once(&addr.to_string(), b"hello", Duration::from_secs(5)).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, Error::Disconnected),
|
||||
"expected Disconnected for truncated response, got: {err}"
|
||||
);
|
||||
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tcp_oversized_length_rejected() {
|
||||
// Server sends a length prefix larger than MAX_KDC_FRAME_SIZE.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
|
||||
// Read request.
|
||||
let mut len_buf = [0u8; 4];
|
||||
let _ = stream.read_exact(&mut len_buf).await;
|
||||
let msg_len = u32::from_be_bytes(len_buf) as usize;
|
||||
let mut discard = vec![0u8; msg_len];
|
||||
let _ = stream.read_exact(&mut discard).await;
|
||||
|
||||
// Send absurdly large length.
|
||||
let resp_len = (MAX_KDC_FRAME_SIZE as u32 + 1).to_be_bytes();
|
||||
stream.write_all(&resp_len).await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
});
|
||||
|
||||
let result = send_tcp_once(&addr.to_string(), b"hello", Duration::from_secs(5)).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err_str = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_str.contains("exceeds maximum"),
|
||||
"expected 'exceeds maximum' error, got: {err_str}"
|
||||
);
|
||||
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
// ── send_to_kdc tests ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_to_kdc_udp_success() {
|
||||
// Set up a UDP mock KDC.
|
||||
let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; UDP_MAX_SIZE];
|
||||
let (n, src) = server.recv_from(&mut buf).await.unwrap();
|
||||
// Respond with a fake AS-REP (not a KRB-ERROR).
|
||||
let response = b"\x6b\x05\x30\x03\x02\x01\x05"; // Fake AS-REP-like
|
||||
server.send_to(response, src).await.unwrap();
|
||||
drop(buf[..n].to_vec()); // acknowledge we received
|
||||
});
|
||||
|
||||
let config = KdcConfig {
|
||||
address: server_addr.to_string(),
|
||||
timeout: Duration::from_secs(5),
|
||||
};
|
||||
|
||||
let result = send_to_kdc(&config, b"as-req").await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), b"\x6b\x05\x30\x03\x02\x01\x05");
|
||||
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_to_kdc_udp_too_big_falls_back_to_tcp() {
|
||||
// Set up a UDP server that returns KRB_ERR_RESPONSE_TOO_BIG
|
||||
// and a TCP server that returns a real response. The fallback
|
||||
// path uses one `KdcConfig.address`, so both servers must share
|
||||
// a port.
|
||||
//
|
||||
// Bind TCP first (more restrictive) and then UDP to its port.
|
||||
// On Windows Server, the OS port allocator can hand out an
|
||||
// ephemeral port that's in an excluded range for the other
|
||||
// protocol (WSAEACCES / 10013). Retry a few times if so;
|
||||
// a fresh `:0` lottery picks a different port each attempt.
|
||||
let (udp_server, tcp_listener) = {
|
||||
let mut last_err: Option<std::io::Error> = None;
|
||||
let mut bound = None;
|
||||
for _ in 0..10 {
|
||||
let tcp = match TcpListener::bind("127.0.0.1:0").await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let port = tcp.local_addr().unwrap().port();
|
||||
match UdpSocket::bind(format!("127.0.0.1:{port}")).await {
|
||||
Ok(udp) => {
|
||||
bound = Some((udp, tcp));
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
// TCP listener drops here; try a new port.
|
||||
}
|
||||
}
|
||||
}
|
||||
bound.unwrap_or_else(|| {
|
||||
panic!("could not co-bind UDP+TCP on a shared loopback port in 10 attempts: {last_err:?}")
|
||||
})
|
||||
};
|
||||
let udp_addr = udp_server.local_addr().unwrap();
|
||||
|
||||
let udp_task = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; UDP_MAX_SIZE];
|
||||
let (_, src) = udp_server.recv_from(&mut buf).await.unwrap();
|
||||
let error = build_krb_error(KRB_ERR_RESPONSE_TOO_BIG);
|
||||
udp_server.send_to(&error, src).await.unwrap();
|
||||
});
|
||||
|
||||
let tcp_task = tokio::spawn(async move {
|
||||
let (mut stream, _) = tcp_listener.accept().await.unwrap();
|
||||
// Read request.
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).await.unwrap();
|
||||
let msg_len = u32::from_be_bytes(len_buf) as usize;
|
||||
let mut msg = vec![0u8; msg_len];
|
||||
stream.read_exact(&mut msg).await.unwrap();
|
||||
|
||||
// Send TCP response.
|
||||
let response = b"tcp-kdc-response";
|
||||
let resp_len = (response.len() as u32).to_be_bytes();
|
||||
stream.write_all(&resp_len).await.unwrap();
|
||||
stream.write_all(response).await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
});
|
||||
|
||||
let config = KdcConfig {
|
||||
address: udp_addr.to_string(),
|
||||
timeout: Duration::from_secs(5),
|
||||
};
|
||||
|
||||
let result = send_to_kdc(&config, b"as-req-large").await;
|
||||
assert!(result.is_ok(), "send_to_kdc failed: {:?}", result.err());
|
||||
assert_eq!(result.unwrap(), b"tcp-kdc-response");
|
||||
|
||||
udp_task.await.unwrap();
|
||||
tcp_task.await.unwrap();
|
||||
}
|
||||
|
||||
// ── discover_kdc tests ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_kdc_returns_empty_placeholder() {
|
||||
let result = discover_kdc("EXAMPLE.COM").await;
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
}
|
||||
1631
vendor/smb2/src/auth/kerberos/messages.rs
vendored
Normal file
1631
vendor/smb2/src/auth/kerberos/messages.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
21
vendor/smb2/src/auth/kerberos/mod.rs
vendored
Normal file
21
vendor/smb2/src/auth/kerberos/mod.rs
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
//! Kerberos authentication support.
|
||||
//!
|
||||
//! Implements the cryptographic operations needed for Kerberos authentication
|
||||
//! (etypes 17, 18, 23): string-to-key, key derivation, AES-CTS encryption,
|
||||
//! RC4-HMAC encryption, and checksum computation.
|
||||
//!
|
||||
//! The [`KerberosAuthenticator`] wires all building blocks together into
|
||||
//! a full Kerberos authentication flow: AS exchange, TGS exchange, and
|
||||
//! AP-REQ construction for SMB2 SESSION_SETUP.
|
||||
//!
|
||||
//! The [`ccache`] module supports reading MIT Kerberos credential caches,
|
||||
//! enabling authentication from cached TGTs or service tickets (for example,
|
||||
//! from `kinit`) without requiring a password.
|
||||
|
||||
pub mod ccache;
|
||||
pub mod crypto;
|
||||
pub mod kdc;
|
||||
pub mod messages;
|
||||
|
||||
mod authenticator;
|
||||
pub use authenticator::{KerberosAuthenticator, KerberosCredentials};
|
||||
15
vendor/smb2/src/auth/mod.rs
vendored
Normal file
15
vendor/smb2/src/auth/mod.rs
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Authentication mechanisms for SMB2.
|
||||
//!
|
||||
//! Supports NTLM authentication (MS-NLMP) and Kerberos authentication
|
||||
//! (RFC 4120, MS-KILE).
|
||||
//!
|
||||
//! Most users don't need this module directly -- [`SmbClient`](crate::SmbClient)
|
||||
//! handles authentication during [`connect`](crate::connect).
|
||||
|
||||
pub(crate) mod der;
|
||||
pub mod kerberos;
|
||||
pub mod ntlm;
|
||||
pub mod spnego;
|
||||
|
||||
pub use kerberos::{KerberosAuthenticator, KerberosCredentials};
|
||||
pub use ntlm::{NtlmAuthenticator, NtlmCredentials};
|
||||
1410
vendor/smb2/src/auth/ntlm.rs
vendored
Normal file
1410
vendor/smb2/src/auth/ntlm.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
808
vendor/smb2/src/auth/spnego.rs
vendored
Normal file
808
vendor/smb2/src/auth/spnego.rs
vendored
Normal file
@@ -0,0 +1,808 @@
|
||||
//! SPNEGO (Simple and Protected GSS-API Negotiation Mechanism) token wrapping.
|
||||
//!
|
||||
//! Implements the thin ASN.1/DER wrapper that SMB2 requires around authentication
|
||||
//! tokens (NTLM, Kerberos). The client sends a NegTokenInit with supported
|
||||
//! mechanism OIDs and the first mechanism's token, the server responds with
|
||||
//! NegTokenResp indicating the selected mechanism and its response token, and
|
||||
//! subsequent client messages use NegTokenResp as well.
|
||||
//!
|
||||
//! References:
|
||||
//! - RFC 4178 (SPNEGO)
|
||||
//! - MS-SPNG (Microsoft SPNEGO Extension)
|
||||
|
||||
use super::der::{der_tlv, parse_der_tlv};
|
||||
use crate::Error;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OID constants (DER-encoded, including tag and length bytes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// SPNEGO OID: 1.3.6.1.5.5.2
|
||||
pub const OID_SPNEGO: &[u8] = &[0x06, 0x06, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x02];
|
||||
|
||||
/// NTLM (NTLMSSP) OID: 1.3.6.1.4.1.311.2.2.10
|
||||
pub const OID_NTLMSSP: &[u8] = &[
|
||||
0x06, 0x0a, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x02, 0x02, 0x0a,
|
||||
];
|
||||
|
||||
/// Kerberos OID: 1.2.840.113554.1.2.2 (standard, RFC 4121)
|
||||
pub const OID_KERBEROS: &[u8] = &[
|
||||
0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x02,
|
||||
];
|
||||
|
||||
/// Microsoft Kerberos OID: 1.2.840.48018.1.2.2 (MS-KILE, used by Windows SPNEGO)
|
||||
///
|
||||
/// Windows expects this OID as the primary mechanism in SPNEGO NegTokenInit.
|
||||
/// Using the standard Kerberos OID causes Windows to reject the AP-REQ.
|
||||
pub const OID_MS_KERBEROS: &[u8] = &[
|
||||
0x06, 0x09, 0x2a, 0x86, 0x48, 0x82, 0xf7, 0x12, 0x01, 0x02, 0x02,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ASN.1 DER tag constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// SEQUENCE tag (constructed).
|
||||
const TAG_SEQUENCE: u8 = 0x30;
|
||||
/// OCTET STRING tag.
|
||||
const TAG_OCTET_STRING: u8 = 0x04;
|
||||
/// ENUMERATED tag.
|
||||
const TAG_ENUMERATED: u8 = 0x0a;
|
||||
/// APPLICATION [0] (constructed) -- wraps the initial NegotiationToken.
|
||||
const TAG_APPLICATION_0: u8 = 0x60;
|
||||
/// Context-specific [0] (constructed).
|
||||
const TAG_CONTEXT_0: u8 = 0xa0;
|
||||
/// Context-specific [1] (constructed).
|
||||
const TAG_CONTEXT_1: u8 = 0xa1;
|
||||
/// Context-specific [2] (constructed).
|
||||
const TAG_CONTEXT_2: u8 = 0xa2;
|
||||
/// Context-specific [3] (constructed).
|
||||
const TAG_CONTEXT_3: u8 = 0xa3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NegState enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// SPNEGO negotiation state from NegTokenResp.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NegState {
|
||||
/// Authentication completed successfully.
|
||||
AcceptCompleted,
|
||||
/// Authentication is in progress (more tokens needed).
|
||||
AcceptIncomplete,
|
||||
/// Authentication was rejected.
|
||||
Reject,
|
||||
}
|
||||
|
||||
impl NegState {
|
||||
/// Parse from the DER enumerated value.
|
||||
fn from_value(v: u8) -> Option<NegState> {
|
||||
match v {
|
||||
0 => Some(NegState::AcceptCompleted),
|
||||
1 => Some(NegState::AcceptIncomplete),
|
||||
2 => Some(NegState::Reject),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NegTokenResp struct
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parsed SPNEGO NegTokenResp from the server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NegTokenResp {
|
||||
/// The negotiation state.
|
||||
pub neg_state: Option<NegState>,
|
||||
/// The selected mechanism OID (raw DER-encoded OID TLV).
|
||||
pub supported_mech: Option<Vec<u8>>,
|
||||
/// The mechanism-specific response token.
|
||||
pub response_token: Option<Vec<u8>>,
|
||||
/// The mechanism list MIC.
|
||||
pub mech_list_mic: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
// DER encoding/decoding helpers are in `super::der`. Imported at the top.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: wrapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wrap a mechanism token in a SPNEGO NegTokenInit.
|
||||
///
|
||||
/// The initial token sent by the client. Wraps the raw NTLM or Kerberos
|
||||
/// token with mechanism OID negotiation.
|
||||
///
|
||||
/// Structure (RFC 4178 section 4.2):
|
||||
/// ```text
|
||||
/// APPLICATION [0] {
|
||||
/// OID_SPNEGO,
|
||||
/// [0] { -- NegTokenInit choice tag
|
||||
/// SEQUENCE {
|
||||
/// [0] { SEQUENCE { mechOID1, mechOID2, ... } }, -- mechTypes
|
||||
/// [2] { OCTET STRING { mechToken } } -- mechToken
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn wrap_neg_token_init(mech_oids: &[&[u8]], mech_token: &[u8]) -> Vec<u8> {
|
||||
// Build mechTypes: SEQUENCE OF OID
|
||||
let mut mech_list_contents = Vec::new();
|
||||
for oid in mech_oids {
|
||||
mech_list_contents.extend_from_slice(oid);
|
||||
}
|
||||
let mech_list_seq = der_tlv(TAG_SEQUENCE, &mech_list_contents);
|
||||
let mech_types = der_tlv(TAG_CONTEXT_0, &mech_list_seq);
|
||||
|
||||
// Build mechToken: [2] OCTET STRING
|
||||
let mech_token_octet = der_tlv(TAG_OCTET_STRING, mech_token);
|
||||
let mech_token_ctx = der_tlv(TAG_CONTEXT_2, &mech_token_octet);
|
||||
|
||||
// NegTokenInit SEQUENCE
|
||||
let mut init_contents = Vec::new();
|
||||
init_contents.extend_from_slice(&mech_types);
|
||||
init_contents.extend_from_slice(&mech_token_ctx);
|
||||
let init_seq = der_tlv(TAG_SEQUENCE, &init_contents);
|
||||
|
||||
// Wrap in context [0] (NegotiationToken CHOICE for negTokenInit)
|
||||
let choice = der_tlv(TAG_CONTEXT_0, &init_seq);
|
||||
|
||||
// Wrap in APPLICATION [0] with SPNEGO OID
|
||||
let mut app_contents = Vec::new();
|
||||
app_contents.extend_from_slice(OID_SPNEGO);
|
||||
app_contents.extend_from_slice(&choice);
|
||||
der_tlv(TAG_APPLICATION_0, &app_contents)
|
||||
}
|
||||
|
||||
/// Wrap a mechanism token in a SPNEGO NegTokenResp.
|
||||
///
|
||||
/// Used by the client in the second round-trip (for example, the NTLM
|
||||
/// AUTHENTICATE_MESSAGE). Only the responseToken field is set.
|
||||
///
|
||||
/// Structure:
|
||||
/// ```text
|
||||
/// [1] { -- NegotiationToken CHOICE for negTokenResp
|
||||
/// SEQUENCE {
|
||||
/// [2] { OCTET STRING { mechToken } } -- responseToken
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn wrap_neg_token_resp(mech_token: &[u8]) -> Vec<u8> {
|
||||
// Build responseToken: [2] OCTET STRING
|
||||
let mech_token_octet = der_tlv(TAG_OCTET_STRING, mech_token);
|
||||
let response_token_ctx = der_tlv(TAG_CONTEXT_2, &mech_token_octet);
|
||||
|
||||
// NegTokenResp SEQUENCE
|
||||
let resp_seq = der_tlv(TAG_SEQUENCE, &response_token_ctx);
|
||||
|
||||
// Wrap in context [1] (NegotiationToken CHOICE for negTokenResp)
|
||||
der_tlv(TAG_CONTEXT_1, &resp_seq)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a SPNEGO NegTokenResp from the server.
|
||||
///
|
||||
/// The input can be either:
|
||||
/// - A bare `[1] { SEQUENCE { ... } }` NegTokenResp
|
||||
/// - An `APPLICATION [0] { OID, [0] { ... } }` wrapping a NegTokenInit2
|
||||
/// (server-initiated SPNEGO, which we parse the inner token from)
|
||||
///
|
||||
/// Extracts the negotiation state, selected mechanism, and response token.
|
||||
pub fn parse_neg_token_resp(data: &[u8]) -> Result<NegTokenResp, Error> {
|
||||
if data.is_empty() {
|
||||
return Err(Error::invalid_data("SPNEGO: empty token"));
|
||||
}
|
||||
|
||||
// Check if this is an APPLICATION [0] wrapper (server-initiated NegTokenInit2)
|
||||
// or a NegTokenResp [1] wrapper.
|
||||
let (tag, value, _) = parse_der_tlv(data)?;
|
||||
|
||||
match tag {
|
||||
TAG_CONTEXT_1 => {
|
||||
// Standard NegTokenResp: [1] { SEQUENCE { ... } }
|
||||
parse_neg_token_resp_inner(value)
|
||||
}
|
||||
TAG_APPLICATION_0 => {
|
||||
// APPLICATION [0] { OID_SPNEGO, [0] { NegTokenInit2 } }
|
||||
// or could contain a [1] { NegTokenResp }
|
||||
// Skip the SPNEGO OID
|
||||
let (oid_tag, _, oid_total) = parse_der_tlv(value)?;
|
||||
if oid_tag != 0x06 {
|
||||
return Err(Error::invalid_data(format!(
|
||||
"SPNEGO: expected OID in APPLICATION [0], got tag 0x{oid_tag:02x}"
|
||||
)));
|
||||
}
|
||||
let remaining = &value[oid_total..];
|
||||
let (inner_tag, inner_value, _) = parse_der_tlv(remaining)?;
|
||||
match inner_tag {
|
||||
TAG_CONTEXT_0 => {
|
||||
// NegTokenInit2 wrapped in [0]: parse as NegTokenInit2
|
||||
// to extract mechTypes (as supportedMech) and mechToken
|
||||
parse_neg_token_init2_as_resp(inner_value)
|
||||
}
|
||||
TAG_CONTEXT_1 => {
|
||||
// NegTokenResp wrapped inside APPLICATION [0]
|
||||
parse_neg_token_resp_inner(inner_value)
|
||||
}
|
||||
_ => Err(Error::invalid_data(format!(
|
||||
"SPNEGO: unexpected tag 0x{inner_tag:02x} inside APPLICATION [0]"
|
||||
))),
|
||||
}
|
||||
}
|
||||
_ => Err(Error::invalid_data(format!(
|
||||
"SPNEGO: expected NegTokenResp [1] or APPLICATION [0], got tag 0x{tag:02x}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the inner SEQUENCE of a NegTokenResp.
|
||||
fn parse_neg_token_resp_inner(data: &[u8]) -> Result<NegTokenResp, Error> {
|
||||
// Expect SEQUENCE
|
||||
let (tag, seq_data, _) = parse_der_tlv(data)?;
|
||||
if tag != TAG_SEQUENCE {
|
||||
return Err(Error::invalid_data(format!(
|
||||
"SPNEGO: expected SEQUENCE in NegTokenResp, got tag 0x{tag:02x}"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut neg_state = None;
|
||||
let mut supported_mech = None;
|
||||
let mut response_token = None;
|
||||
let mut mech_list_mic = None;
|
||||
|
||||
let mut pos = 0;
|
||||
while pos < seq_data.len() {
|
||||
let (ctx_tag, ctx_value, ctx_total) = parse_der_tlv(&seq_data[pos..])?;
|
||||
match ctx_tag {
|
||||
TAG_CONTEXT_0 => {
|
||||
// negState: ENUMERATED
|
||||
let (enum_tag, enum_value, _) = parse_der_tlv(ctx_value)?;
|
||||
if enum_tag != TAG_ENUMERATED {
|
||||
return Err(Error::invalid_data(format!(
|
||||
"SPNEGO: expected ENUMERATED for negState, got tag 0x{enum_tag:02x}"
|
||||
)));
|
||||
}
|
||||
if enum_value.is_empty() {
|
||||
return Err(Error::invalid_data("SPNEGO: empty ENUMERATED for negState"));
|
||||
}
|
||||
neg_state = NegState::from_value(enum_value[0]);
|
||||
if neg_state.is_none() {
|
||||
return Err(Error::invalid_data(format!(
|
||||
"SPNEGO: unknown negState value: {}",
|
||||
enum_value[0]
|
||||
)));
|
||||
}
|
||||
}
|
||||
TAG_CONTEXT_1 => {
|
||||
// supportedMech: OID (the full TLV)
|
||||
supported_mech = Some(ctx_value.to_vec());
|
||||
}
|
||||
TAG_CONTEXT_2 => {
|
||||
// responseToken: OCTET STRING
|
||||
let (oct_tag, oct_value, _) = parse_der_tlv(ctx_value)?;
|
||||
if oct_tag != TAG_OCTET_STRING {
|
||||
return Err(Error::invalid_data(format!(
|
||||
"SPNEGO: expected OCTET STRING for responseToken, got tag 0x{oct_tag:02x}"
|
||||
)));
|
||||
}
|
||||
response_token = Some(oct_value.to_vec());
|
||||
}
|
||||
TAG_CONTEXT_3 => {
|
||||
// mechListMIC: OCTET STRING
|
||||
let (oct_tag, oct_value, _) = parse_der_tlv(ctx_value)?;
|
||||
if oct_tag != TAG_OCTET_STRING {
|
||||
return Err(Error::invalid_data(format!(
|
||||
"SPNEGO: expected OCTET STRING for mechListMIC, got tag 0x{oct_tag:02x}"
|
||||
)));
|
||||
}
|
||||
mech_list_mic = Some(oct_value.to_vec());
|
||||
}
|
||||
_ => {
|
||||
// Unknown context tag, skip it (forward compatibility).
|
||||
}
|
||||
}
|
||||
pos += ctx_total;
|
||||
}
|
||||
|
||||
Ok(NegTokenResp {
|
||||
neg_state,
|
||||
supported_mech,
|
||||
response_token,
|
||||
mech_list_mic,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a NegTokenInit2 (server-initiated) and return it as a NegTokenResp.
|
||||
///
|
||||
/// NegTokenInit2 has mechTypes at [0] and mechToken at [2]. We map the
|
||||
/// first mechType to supportedMech and mechToken to responseToken.
|
||||
fn parse_neg_token_init2_as_resp(data: &[u8]) -> Result<NegTokenResp, Error> {
|
||||
let (tag, seq_data, _) = parse_der_tlv(data)?;
|
||||
if tag != TAG_SEQUENCE {
|
||||
return Err(Error::invalid_data(format!(
|
||||
"SPNEGO: expected SEQUENCE in NegTokenInit2, got tag 0x{tag:02x}"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut supported_mech = None;
|
||||
let mut response_token = None;
|
||||
|
||||
let mut pos = 0;
|
||||
while pos < seq_data.len() {
|
||||
let (ctx_tag, ctx_value, ctx_total) = parse_der_tlv(&seq_data[pos..])?;
|
||||
match ctx_tag {
|
||||
TAG_CONTEXT_0 => {
|
||||
// mechTypes: SEQUENCE OF OID -- take the first one
|
||||
let (seq_tag, mech_list_data, _) = parse_der_tlv(ctx_value)?;
|
||||
if seq_tag != TAG_SEQUENCE {
|
||||
return Err(Error::invalid_data(
|
||||
"SPNEGO: expected SEQUENCE for mechTypes",
|
||||
));
|
||||
}
|
||||
if !mech_list_data.is_empty() {
|
||||
// Take the first OID TLV as the supported mech
|
||||
let (oid_tag, _, oid_total) = parse_der_tlv(mech_list_data)?;
|
||||
if oid_tag == 0x06 {
|
||||
supported_mech = Some(mech_list_data[..oid_total].to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
TAG_CONTEXT_2 => {
|
||||
// mechToken: OCTET STRING
|
||||
let (oct_tag, oct_value, _) = parse_der_tlv(ctx_value)?;
|
||||
if oct_tag == TAG_OCTET_STRING {
|
||||
response_token = Some(oct_value.to_vec());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Skip reqFlags [1], negHints [3], mechListMIC [4]
|
||||
}
|
||||
}
|
||||
pos += ctx_total;
|
||||
}
|
||||
|
||||
Ok(NegTokenResp {
|
||||
neg_state: None,
|
||||
supported_mech,
|
||||
response_token,
|
||||
mech_list_mic: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// DER primitive tests (der_length, der_tlv, parse_der_length, parse_der_tlv)
|
||||
// live in `auth::der::tests`.
|
||||
|
||||
// =======================================================================
|
||||
// NegTokenInit wrapping tests
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn neg_token_init_starts_with_application_tag() {
|
||||
let token = wrap_neg_token_init(&[OID_NTLMSSP], b"NTLMSSP\0test");
|
||||
assert_eq!(
|
||||
token[0], TAG_APPLICATION_0,
|
||||
"must start with APPLICATION [0]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neg_token_init_contains_spnego_oid() {
|
||||
let token = wrap_neg_token_init(&[OID_NTLMSSP], b"NTLMSSP\0test");
|
||||
// The SPNEGO OID value bytes (without the 0x06 tag and 0x06 length)
|
||||
let oid_value = &OID_SPNEGO[2..]; // skip tag+length
|
||||
assert!(
|
||||
token.windows(oid_value.len()).any(|w| w == oid_value),
|
||||
"token must contain SPNEGO OID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neg_token_init_contains_mech_oid() {
|
||||
let token = wrap_neg_token_init(&[OID_NTLMSSP], b"test");
|
||||
// The NTLMSSP OID value bytes (without the 0x06 tag)
|
||||
let oid_value = &OID_NTLMSSP[2..]; // skip tag+length
|
||||
assert!(
|
||||
token.windows(oid_value.len()).any(|w| w == oid_value),
|
||||
"token must contain NTLMSSP OID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neg_token_init_contains_mech_token() {
|
||||
let mech_token = b"NTLMSSP\0negotiate_payload_here";
|
||||
let token = wrap_neg_token_init(&[OID_NTLMSSP], mech_token);
|
||||
assert!(
|
||||
token.windows(mech_token.len()).any(|w| w == mech_token),
|
||||
"token must contain the raw mech token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neg_token_init_multiple_mechs() {
|
||||
let token = wrap_neg_token_init(&[OID_NTLMSSP, OID_KERBEROS], b"tok");
|
||||
// Both OIDs should be present
|
||||
let ntlm_oid_value = &OID_NTLMSSP[2..];
|
||||
let kerb_oid_value = &OID_KERBEROS[2..];
|
||||
assert!(
|
||||
token
|
||||
.windows(ntlm_oid_value.len())
|
||||
.any(|w| w == ntlm_oid_value),
|
||||
"must contain NTLMSSP OID"
|
||||
);
|
||||
assert!(
|
||||
token
|
||||
.windows(kerb_oid_value.len())
|
||||
.any(|w| w == kerb_oid_value),
|
||||
"must contain Kerberos OID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neg_token_init_structure_is_valid_der() {
|
||||
let token = wrap_neg_token_init(&[OID_NTLMSSP], b"test_token");
|
||||
// Parse the outer APPLICATION [0]
|
||||
let (tag, value, total) = parse_der_tlv(&token).unwrap();
|
||||
assert_eq!(tag, TAG_APPLICATION_0);
|
||||
assert_eq!(total, token.len(), "entire token should be consumed");
|
||||
|
||||
// Inside: OID_SPNEGO followed by [0] { SEQUENCE { ... } }
|
||||
let (oid_tag, _, oid_total) = parse_der_tlv(value).unwrap();
|
||||
assert_eq!(oid_tag, 0x06, "first element should be OID");
|
||||
|
||||
let (choice_tag, _, _) = parse_der_tlv(&value[oid_total..]).unwrap();
|
||||
assert_eq!(choice_tag, TAG_CONTEXT_0, "second element should be [0]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neg_token_init_parseable_structure() {
|
||||
// Wrap a token and verify we can walk the entire structure
|
||||
let mech_token = b"the_raw_ntlm_token";
|
||||
let token = wrap_neg_token_init(&[OID_NTLMSSP], mech_token);
|
||||
|
||||
// APPLICATION [0]
|
||||
let (_, app_value, _) = parse_der_tlv(&token).unwrap();
|
||||
// Skip SPNEGO OID
|
||||
let (_, _, oid_total) = parse_der_tlv(app_value).unwrap();
|
||||
// [0] CHOICE
|
||||
let (_, choice_value, _) = parse_der_tlv(&app_value[oid_total..]).unwrap();
|
||||
// SEQUENCE
|
||||
let (_, seq_value, _) = parse_der_tlv(choice_value).unwrap();
|
||||
// [0] mechTypes
|
||||
let (tag0, ctx0_value, ctx0_total) = parse_der_tlv(seq_value).unwrap();
|
||||
assert_eq!(tag0, TAG_CONTEXT_0);
|
||||
// SEQUENCE OF OID inside mechTypes
|
||||
let (_, mech_list, _) = parse_der_tlv(ctx0_value).unwrap();
|
||||
// First OID should be NTLMSSP
|
||||
assert_eq!(&mech_list[..OID_NTLMSSP.len()], OID_NTLMSSP);
|
||||
|
||||
// [2] mechToken
|
||||
let (tag2, ctx2_value, _) = parse_der_tlv(&seq_value[ctx0_total..]).unwrap();
|
||||
assert_eq!(tag2, TAG_CONTEXT_2);
|
||||
// OCTET STRING
|
||||
let (_, oct_value, _) = parse_der_tlv(ctx2_value).unwrap();
|
||||
assert_eq!(oct_value, mech_token);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// NegTokenResp wrapping tests
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn neg_token_resp_wrap_starts_with_context_1() {
|
||||
let token = wrap_neg_token_resp(b"auth_token");
|
||||
assert_eq!(token[0], TAG_CONTEXT_1, "must start with [1]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neg_token_resp_wrap_contains_mech_token() {
|
||||
let mech_token = b"NTLMSSP\0authenticate_payload";
|
||||
let token = wrap_neg_token_resp(mech_token);
|
||||
assert!(
|
||||
token.windows(mech_token.len()).any(|w| w == mech_token),
|
||||
"wrapped token must contain the raw mech token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neg_token_resp_wrap_valid_structure() {
|
||||
let mech_token = b"authenticate_me";
|
||||
let token = wrap_neg_token_resp(mech_token);
|
||||
|
||||
// [1]
|
||||
let (tag, ctx1_value, _) = parse_der_tlv(&token).unwrap();
|
||||
assert_eq!(tag, TAG_CONTEXT_1);
|
||||
// SEQUENCE
|
||||
let (tag, seq_value, _) = parse_der_tlv(ctx1_value).unwrap();
|
||||
assert_eq!(tag, TAG_SEQUENCE);
|
||||
// [2] responseToken
|
||||
let (tag, ctx2_value, _) = parse_der_tlv(seq_value).unwrap();
|
||||
assert_eq!(tag, TAG_CONTEXT_2);
|
||||
// OCTET STRING
|
||||
let (tag, oct_value, _) = parse_der_tlv(ctx2_value).unwrap();
|
||||
assert_eq!(tag, TAG_OCTET_STRING);
|
||||
assert_eq!(oct_value, mech_token);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// NegTokenResp parsing tests
|
||||
// =======================================================================
|
||||
|
||||
/// Build a NegTokenResp with known fields for testing.
|
||||
fn build_test_neg_token_resp(
|
||||
neg_state: Option<u8>,
|
||||
supported_mech: Option<&[u8]>,
|
||||
response_token: Option<&[u8]>,
|
||||
mech_list_mic: Option<&[u8]>,
|
||||
) -> Vec<u8> {
|
||||
let mut seq_contents = Vec::new();
|
||||
|
||||
if let Some(state) = neg_state {
|
||||
let enumerated = der_tlv(TAG_ENUMERATED, &[state]);
|
||||
seq_contents.extend_from_slice(&der_tlv(TAG_CONTEXT_0, &enumerated));
|
||||
}
|
||||
|
||||
if let Some(oid) = supported_mech {
|
||||
seq_contents.extend_from_slice(&der_tlv(TAG_CONTEXT_1, oid));
|
||||
}
|
||||
|
||||
if let Some(tok) = response_token {
|
||||
let octet = der_tlv(TAG_OCTET_STRING, tok);
|
||||
seq_contents.extend_from_slice(&der_tlv(TAG_CONTEXT_2, &octet));
|
||||
}
|
||||
|
||||
if let Some(mic) = mech_list_mic {
|
||||
let octet = der_tlv(TAG_OCTET_STRING, mic);
|
||||
seq_contents.extend_from_slice(&der_tlv(TAG_CONTEXT_3, &octet));
|
||||
}
|
||||
|
||||
let seq = der_tlv(TAG_SEQUENCE, &seq_contents);
|
||||
der_tlv(TAG_CONTEXT_1, &seq)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_neg_token_resp_accept_incomplete() {
|
||||
let token = build_test_neg_token_resp(
|
||||
Some(1), // accept-incomplete
|
||||
Some(OID_NTLMSSP),
|
||||
Some(b"challenge_token"),
|
||||
None,
|
||||
);
|
||||
|
||||
let resp = parse_neg_token_resp(&token).unwrap();
|
||||
assert_eq!(resp.neg_state, Some(NegState::AcceptIncomplete));
|
||||
assert_eq!(resp.supported_mech.as_deref(), Some(OID_NTLMSSP));
|
||||
assert_eq!(
|
||||
resp.response_token.as_deref(),
|
||||
Some(&b"challenge_token"[..])
|
||||
);
|
||||
assert!(resp.mech_list_mic.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_neg_token_resp_accept_completed() {
|
||||
let token = build_test_neg_token_resp(Some(0), None, None, None);
|
||||
|
||||
let resp = parse_neg_token_resp(&token).unwrap();
|
||||
assert_eq!(resp.neg_state, Some(NegState::AcceptCompleted));
|
||||
assert!(resp.supported_mech.is_none());
|
||||
assert!(resp.response_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_neg_token_resp_reject() {
|
||||
let token = build_test_neg_token_resp(Some(2), None, None, None);
|
||||
|
||||
let resp = parse_neg_token_resp(&token).unwrap();
|
||||
assert_eq!(resp.neg_state, Some(NegState::Reject));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_neg_token_resp_all_fields() {
|
||||
let token = build_test_neg_token_resp(
|
||||
Some(1),
|
||||
Some(OID_NTLMSSP),
|
||||
Some(b"response_data"),
|
||||
Some(b"mic_data"),
|
||||
);
|
||||
|
||||
let resp = parse_neg_token_resp(&token).unwrap();
|
||||
assert_eq!(resp.neg_state, Some(NegState::AcceptIncomplete));
|
||||
assert_eq!(resp.supported_mech.as_deref(), Some(OID_NTLMSSP));
|
||||
assert_eq!(resp.response_token.as_deref(), Some(&b"response_data"[..]));
|
||||
assert_eq!(resp.mech_list_mic.as_deref(), Some(&b"mic_data"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_neg_token_resp_no_fields() {
|
||||
// All fields optional
|
||||
let token = build_test_neg_token_resp(None, None, None, None);
|
||||
|
||||
let resp = parse_neg_token_resp(&token).unwrap();
|
||||
assert!(resp.neg_state.is_none());
|
||||
assert!(resp.supported_mech.is_none());
|
||||
assert!(resp.response_token.is_none());
|
||||
assert!(resp.mech_list_mic.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_neg_token_resp_empty_data_error() {
|
||||
let result = parse_neg_token_resp(&[]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_neg_token_resp_truncated_error() {
|
||||
// Just a tag byte, no length
|
||||
let result = parse_neg_token_resp(&[TAG_CONTEXT_1]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_neg_token_resp_wrong_tag_error() {
|
||||
// SEQUENCE tag instead of [1]
|
||||
let data = der_tlv(TAG_SEQUENCE, &[0x00]);
|
||||
let result = parse_neg_token_resp(&data);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_neg_token_resp_unknown_neg_state_error() {
|
||||
let token = build_test_neg_token_resp(Some(99), None, None, None);
|
||||
let result = parse_neg_token_resp(&token);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Cross-validation: construct a realistic server response
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn parse_realistic_server_challenge_response() {
|
||||
// Simulate a typical Samba/Windows SPNEGO response to the first
|
||||
// SESSION_SETUP: accept-incomplete with NTLMSSP OID and an NTLM
|
||||
// challenge token.
|
||||
let ntlm_challenge = b"NTLMSSP\0\x02\x00\x00\x00fake_challenge_data";
|
||||
|
||||
let token = build_test_neg_token_resp(
|
||||
Some(1), // accept-incomplete
|
||||
Some(OID_NTLMSSP),
|
||||
Some(ntlm_challenge),
|
||||
None,
|
||||
);
|
||||
|
||||
let resp = parse_neg_token_resp(&token).unwrap();
|
||||
assert_eq!(resp.neg_state, Some(NegState::AcceptIncomplete));
|
||||
assert_eq!(resp.response_token.as_deref(), Some(&ntlm_challenge[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_realistic_server_accept_with_mic() {
|
||||
// Final server response: accept-completed with mechListMIC
|
||||
let mic = [0xaa; 16];
|
||||
let token = build_test_neg_token_resp(Some(0), None, None, Some(&mic));
|
||||
|
||||
let resp = parse_neg_token_resp(&token).unwrap();
|
||||
assert_eq!(resp.neg_state, Some(NegState::AcceptCompleted));
|
||||
assert_eq!(resp.mech_list_mic.as_deref(), Some(&mic[..]));
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Roundtrip: wrap and parse NegTokenResp
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn neg_token_resp_wrap_then_parse() {
|
||||
let mech_token = b"roundtrip_test_token";
|
||||
let wrapped = wrap_neg_token_resp(mech_token);
|
||||
let parsed = parse_neg_token_resp(&wrapped).unwrap();
|
||||
|
||||
// Wrapped with only responseToken, so:
|
||||
assert!(parsed.neg_state.is_none());
|
||||
assert!(parsed.supported_mech.is_none());
|
||||
assert_eq!(parsed.response_token.as_deref(), Some(&mech_token[..]));
|
||||
assert!(parsed.mech_list_mic.is_none());
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Wire capture cross-validation
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn parse_hand_constructed_wire_bytes() {
|
||||
// Hand-constructed NegTokenResp matching what a Windows/Samba server
|
||||
// sends after receiving NegTokenInit with NTLMSSP:
|
||||
//
|
||||
// a1 XX -- [1] NegTokenResp
|
||||
// 30 XX -- SEQUENCE
|
||||
// a0 03 -- [0] negState
|
||||
// 0a 01 01 -- ENUMERATED accept-incomplete (1)
|
||||
// a1 0c -- [1] supportedMech
|
||||
// 06 0a 2b 06 01 04 01 82 37 02 02 0a -- NTLMSSP OID
|
||||
// a2 XX -- [2] responseToken
|
||||
// 04 XX -- OCTET STRING
|
||||
// <ntlm challenge bytes>
|
||||
let ntlm_challenge = b"NTLMSSP\0fake";
|
||||
|
||||
// Build by hand
|
||||
let neg_state_enum = vec![0x0a, 0x01, 0x01]; // ENUMERATED 1
|
||||
let neg_state_ctx = der_tlv(TAG_CONTEXT_0, &neg_state_enum);
|
||||
|
||||
let mech_ctx = der_tlv(TAG_CONTEXT_1, OID_NTLMSSP);
|
||||
|
||||
let resp_octet = der_tlv(TAG_OCTET_STRING, ntlm_challenge);
|
||||
let resp_ctx = der_tlv(TAG_CONTEXT_2, &resp_octet);
|
||||
|
||||
let mut seq_content = Vec::new();
|
||||
seq_content.extend_from_slice(&neg_state_ctx);
|
||||
seq_content.extend_from_slice(&mech_ctx);
|
||||
seq_content.extend_from_slice(&resp_ctx);
|
||||
let seq = der_tlv(TAG_SEQUENCE, &seq_content);
|
||||
let wire_bytes = der_tlv(TAG_CONTEXT_1, &seq);
|
||||
|
||||
let parsed = parse_neg_token_resp(&wire_bytes).unwrap();
|
||||
assert_eq!(parsed.neg_state, Some(NegState::AcceptIncomplete));
|
||||
assert_eq!(parsed.supported_mech.as_deref(), Some(OID_NTLMSSP));
|
||||
assert_eq!(parsed.response_token.as_deref(), Some(&ntlm_challenge[..]));
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// OID constant verification
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn oid_constants_are_valid_der() {
|
||||
// Each OID constant should parse as a valid DER TLV with tag 0x06
|
||||
for (name, oid) in [
|
||||
("SPNEGO", OID_SPNEGO),
|
||||
("NTLMSSP", OID_NTLMSSP),
|
||||
("Kerberos", OID_KERBEROS),
|
||||
] {
|
||||
let (tag, _, total) =
|
||||
parse_der_tlv(oid).unwrap_or_else(|e| panic!("{name} OID is not valid DER: {e}"));
|
||||
assert_eq!(tag, 0x06, "{name} OID tag should be 0x06");
|
||||
assert_eq!(total, oid.len(), "{name} OID should be fully consumed");
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Large token handling
|
||||
// =======================================================================
|
||||
|
||||
#[test]
|
||||
fn neg_token_init_with_large_mech_token() {
|
||||
// Kerberos tokens can be several KB
|
||||
let large_token = vec![0xab; 4096];
|
||||
let wrapped = wrap_neg_token_init(&[OID_KERBEROS], &large_token);
|
||||
|
||||
// Should parse without error
|
||||
let (tag, _, total) = parse_der_tlv(&wrapped).unwrap();
|
||||
assert_eq!(tag, TAG_APPLICATION_0);
|
||||
assert_eq!(total, wrapped.len());
|
||||
|
||||
// The large token should be embedded
|
||||
assert!(
|
||||
wrapped.windows(100).any(|w| w == &large_token[..100]),
|
||||
"large token content must be present"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neg_token_resp_with_large_response_token() {
|
||||
let large_token = vec![0xcd; 4096];
|
||||
let built = build_test_neg_token_resp(Some(1), None, Some(&large_token), None);
|
||||
let parsed = parse_neg_token_resp(&built).unwrap();
|
||||
assert_eq!(parsed.response_token.as_deref(), Some(&large_token[..]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user