///|
/// Parsed SASL DIGEST-MD5 challenge directives (RFC 2831 2.1.1).
pub struct DigestChallenge {
realms : Array[String]
nonce : String
qop_options : Array[String]
charset : String?
} derive(Eq, @debug.Debug)
///|
fn split_directives(s : String) -> Array[(String, String)] {
let out : Array[(String, String)] = []
let mut rest = s
while !rest.is_empty() {
// Take one directive up to the next top-level comma.
let mut in_quotes = false
let mut end = rest.length()
for i, ch in rest {
if ch == '"' {
in_quotes = !in_quotes
} else if ch == ',' && !in_quotes {
end = i
break
}
}
let piece = take_chars(rest, end)
match find_char(piece, '=') {
Some(eq) =>
out.push(
(
trim_spaces(take_chars(piece, eq)),
trim_spaces(skip_chars(piece, eq + 1)),
),
)
None => ()
}
rest = skip_chars(rest, end + 1)
// skip leading spaces of next directive
while !rest.is_empty() && rest[0] == ' ' {
rest = skip_chars(rest, 1)
}
}
out
}
///|
fn trim_spaces(s : String) -> String {
let start = find_first_not(s, ' ')
let last = find_last_not(s, ' ')
match (start, last) {
(Some(a), Some(b)) => take_chars(skip_chars(s, a), b - a + 1)
_ => ""
}
}
///|
fn find_first_not(s : String, target : Char) -> Int? {
for i, ch in s {
if ch != target {
return Some(i)
}
}
None
}
///|
fn find_last_not(s : String, target : Char) -> Int? {
let mut best : Int? = None
for i, ch in s {
if ch != target {
best = Some(i)
}
}
best
}
///|
fn unquote(v : String) -> String {
if v.has_prefix("\"") && v.has_suffix("\"") && v.length() >= 2 {
take_chars(skip_chars(v, 1), v.length() - 2)
} else {
v
}
}
///|
/// Parse the BASE64-decoded challenge into directives. Fails when no nonce
/// is present (RFC 2831 requires it).
pub fn parse_digest_challenge(
data : Bytes,
) -> Result[DigestChallenge, LdapError] {
let text = @utf8.decode_lossy(data[:])
let realms : Array[String] = []
let mut nonce = ""
let qops : Array[String] = []
let mut charset : String? = None
for directive in split_directives(text) {
let (name, raw_value) = directive
let value = unquote(raw_value)
match name {
"realm" => realms.push(value)
"nonce" => nonce = value
"qop" =>
for opt in split_all(value, ',') {
qops.push(trim_spaces(opt))
}
"charset" => charset = Some(value)
_ => ()
}
}
if nonce.is_empty() {
return Err(LdapError::Decode("digest challenge has no nonce"))
}
Ok({ realms, nonce, qop_options: qops, charset })
}
///|
fn md5_of(s : String) -> String {
md5_hex(@utf8.encode(s))
}
///|
/// Compute the DIGEST-MD5 `response` directive string (RFC 2831 2.1.2.1)
/// with qop=auth. The returned string is the credentials value sent back to
/// the server in the second bind.
pub fn compute_digest_md5_response(
username~ : String,
password~ : String,
realm~ : String,
nonce~ : String,
cnonce~ : String,
nc~ : String,
qop~ : String,
digest_uri~ : String,
) -> String {
let ha1 = md5_of("\{username}:\{realm}:\{password}")
let ha2 = md5_of("AUTHENTICATE:\{digest_uri}")
let response = md5_hex(
@utf8.encode("\{ha1}:\{nonce}:\{nc}:\{cnonce}:\{qop}:\{ha2}"),
)
let buf = StringBuilder()
buf.write_string("username=\"\{username}\"")
if !realm.is_empty() {
buf.write_string(",realm=\"\{realm}\"")
}
buf.write_string(",nonce=\"\{nonce}\"")
buf.write_string(",cnonce=\"\{cnonce}\"")
buf.write_string(",nc=\{nc},qop=\{qop}")
buf.write_string(",digest-uri=\"\{digest_uri}\"")
buf.write_string(",response=\{response}")
buf.write_string(",charset=utf-8")
buf.to_string()
}
///|
/// Build an EXTERNAL SASL bind request (RFC 4422 appendix A / RFC 4616
/// style): the authzid travels as the SASL credentials; authentication is
/// derived from the transport layer.
pub fn sasl_external_bind_request(authzid : String) -> BindRequest {
BindRequest::sasl(
3,
"",
SaslCredentials::new("EXTERNAL", @utf8.encode(authzid)),
)
}
///|
/// Perform the two-step DIGEST-MD5 bind exchange (RFC 2831): an initial
/// empty SASL bind, parsing the server challenge, then answering with the
/// computed response. `cnonce` can be supplied for deterministic tests;
/// when omitted it is derived from the nonce.
pub async fn[T : LdapTransport] Session::bind_digest_md5(
self : Session[T],
username : String,
password : String,
digest_uri? : String,
cnonce? : String,
) -> Result[BindResponse, LdapError] {
let initial = BindRequest::sasl(
3,
"",
SaslCredentials::new("DIGEST-MD5", Bytes::new(0)),
)
let first = match self.bind_op(initial) {
Ok(r) => r
Err(e) => return Err(e)
}
if first.result.result_code == Success {
return Ok(first)
}
if first.result.result_code != SaslBindInProgress {
return Ok(first)
}
let challenge_b64 = match first.server_sasl_creds {
Some(c) => c
None => return Err(LdapError::Decode("digest-md5 challenge missing"))
}
let challenge = @base64.decode(@utf8.decode_lossy(challenge_b64[:])) catch {
_ => return Err(LdapError::Decode("invalid base64 in digest challenge"))
}
let parsed = match parse_digest_challenge(challenge) {
Ok(c) => c
Err(e) => return Err(e)
}
let realm = if parsed.realms.is_empty() { "" } else { parsed.realms[0] }
let uri = match digest_uri {
Some(u) => u
None => "ldap/\{self.config.host}"
}
let client_nonce = match cnonce {
Some(c) => c
None => md5_hex(@utf8.encode("\{username}:\{realm}:\{parsed.nonce}"))
}
let credentials = compute_digest_md5_response(
username~,
password~,
realm~,
nonce=parsed.nonce,
cnonce=client_nonce,
nc="00000001",
qop="auth",
digest_uri=uri,
)
let second = BindRequest::sasl(
3,
"",
SaslCredentials::new("DIGEST-MD5", @utf8.encode(credentials)),
)
self.bind_op(second)
}