diff --git a/Cargo.toml b/Cargo.toml index 4ec4258..cf3e248 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ name = "rasn" crate-type = ["cdylib"] [dependencies] +libgssapi = "0.9.1" mlua = { version = "0.8.6", features = ["module", "send", "luajit"] } rasn = "0.6.1" rasn-ldap = "0.6.0" diff --git a/lib/resty/ldap/client.lua b/lib/resty/ldap/client.lua index 442dbfc..a78add9 100644 --- a/lib/resty/ldap/client.lua +++ b/lib/resty/ldap/client.lua @@ -12,6 +12,7 @@ local fmt = string.format local tcp = ngx.socket.tcp local table_insert = table.insert local string_char = string.char +local rasn_gssapi_new = rasn.gssapi_new local rasn_decode = rasn.decode_ldap @@ -88,7 +89,7 @@ local function _start_tls(sock) end end -local function _init_socket(self) +local function _init_socket(self, pool_suffix) local host = self.host local port = self.port local socket_config = self.socket_config @@ -96,10 +97,10 @@ local function _init_socket(self) sock:settimeout(socket_config.socket_timeout) - -- keep TLS connections in a separate pool to avoid reusing non-secure - -- connections and vice-versa, because STARTTLS use the same port + -- keep TLS and GSSAPI connections in separate pools to avoid reusing + -- connections with different security/auth properties local opts = { - pool = host .. ":" .. port .. (socket_config.start_tls and ":starttls" or ""), + pool = host .. ":" .. port .. (socket_config.start_tls and ":starttls" or "") .. (pool_suffix or ""), pool_size = socket_config.keepalive_pool_size, } @@ -143,6 +144,38 @@ local function _init_socket(self) self.socket = sock end +-- Send one request and receive one response on an already-open socket. +-- Does not touch the connection pool — caller is responsible for keepalive. +local function _bind_exchange(socket, request) + local bytes, err = socket:send(request) + if not bytes then + return nil, fmt("send request failed: %s", err) + end + + local len, err = socket:receive(2) + if not len then + socket:close() + return nil, fmt("receive response header failed: %s", err) + end + local _, packet_len, packet_header = calculate_payload_length(len, 2, socket) + + local packet, err = socket:receive(packet_len) + if not packet then + socket:close() + return nil, fmt("receive response failed: %s", err) + end + + local ok, res, err = pcall(rasn_decode, packet_header .. packet) + if not ok or err then + return nil, fmt("failed to decode ldap message: %s, message: %s", + not ok and res or err, + to_hex(packet_header .. packet)) + end + + return res +end + + local function _send_recieve(cli, request, multi_resp_hint) -- initialize socket local err = _init_socket(cli) @@ -280,6 +313,131 @@ function _M.simple_bind(self, dn, password) end +-- Authenticate using SASL/GSSAPI (Kerberos). +-- service_name: GSSAPI host-based service name, e.g. "ldap@ldap.example.com" +-- Uses a dedicated connection pool (":gssapi" suffix) so that authenticated +-- connections are never mixed with unauthenticated ones. Reused connections +-- are assumed to already be authenticated and skip the handshake (Option B). +function _M.gssapi_bind(self, service_name) + local err = _init_socket(self, ":gssapi") + if err then + return false, fmt("initialize socket failed: %s", err) + end + + local sock = self.socket + + local reused, err = sock:getreusedtimes() + if not reused then + return false, fmt("getreusedtimes failed: %s", err) + end + + -- Reused connections are already authenticated; skip the handshake. + -- Return the connection to the pool so the subsequent search() reuses it. + if reused > 0 then + sock:setkeepalive(self.socket_config.keepalive_timeout) + return true + end + + local ok, ctx = pcall(rasn_gssapi_new, service_name) + if not ok then + sock:close() + return false, fmt("GSSAPI context init failed: %s", ctx) + end + + -- GSSAPI token exchange (RFC 4752 §3.1). + -- Loop until the GSSAPI context is established, sending/receiving tokens + -- via SASL BindRequest / saslBindInProgress (result code 14). + local server_token = nil + local gss_complete = false + + while not gss_complete do + local step_ok, out_token, complete = pcall(ctx.step, ctx, server_token) + if not step_ok then + sock:close() + return false, fmt("GSSAPI step failed: %s", out_token) + end + gss_complete = complete + + local res, err = _bind_exchange(sock, protocol.sasl_bind_request("GSSAPI", out_token)) + if not res then + sock:close() + return false, fmt("GSSAPI bind exchange failed: %s", err) + end + + if res.protocol_op ~= protocol.APP_NO.BindResponse then + sock:close() + return false, fmt("received incorrect op in packet: %d, expected %d", + res.protocol_op, protocol.APP_NO.BindResponse) + end + + if res.result_code == 0 then + -- Server completed without a security layer offer (atypical but valid). + sock:setkeepalive(self.socket_config.keepalive_timeout) + return true + elseif res.result_code == 14 then + server_token = res.server_sasl_creds + else + sock:close() + local error_msg = protocol.ERROR_MSG[res.result_code] + return false, fmt("GSSAPI bind failed, error: %s, details: %s", + error_msg or ("Unknown error occurred (code: " .. res.result_code .. ")"), + res.diagnostic_msg or "") + end + end + + -- Security layer negotiation (RFC 4752 §3.1). + -- server_token is now the GSSAPI-wrapped 4-byte offer: + -- byte 0: bitmask of supported QOP (1=none, 2=integrity, 4=confidentiality) + -- bytes 1-3: server's maximum message size (big-endian) + if not (server_token and #server_token > 0) then + sock:close() + return false, "GSSAPI: server did not send security layer offer" + end + + local unwrap_ok, offer = pcall(ctx.unwrap, ctx, server_token) + if not unwrap_ok then + sock:close() + return false, fmt("GSSAPI unwrap failed: %s", offer) + end + + if type(offer) ~= "string" or #offer < 4 then + sock:close() + return false, "GSSAPI: server security layer offer must be at least 4 bytes" + end + + -- RFC 4752 §3.1: bit 0 of the first byte signals "no security layer" support. + if string.byte(offer, 1) % 2 == 0 then + sock:close() + return false, "GSSAPI: server does not support no-security-layer (QoP bit 0 not set)" + end + + -- Respond with SSF=0: no per-message security layer (we rely on TLS). + -- byte 0 = 0x01 (no security layer), bytes 1-3 = 0x000000 (max buf size 0) + local wrap_ok, wrapped = pcall(ctx.wrap, ctx, string_char(1, 0, 0, 0)) + if not wrap_ok then + sock:close() + return false, fmt("GSSAPI wrap failed: %s", wrapped) + end + + local res, err = _bind_exchange(sock, protocol.sasl_bind_request("GSSAPI", wrapped)) + if not res then + sock:close() + return false, fmt("GSSAPI SSF exchange failed: %s", err) + end + + if res.result_code ~= 0 then + sock:close() + local error_msg = protocol.ERROR_MSG[res.result_code] + return false, fmt("GSSAPI SSF negotiation failed, error: %s, details: %s", + error_msg or ("Unknown error occurred (code: " .. res.result_code .. ")"), + res.diagnostic_msg or "") + end + + sock:setkeepalive(self.socket_config.keepalive_timeout) + return true +end + + function _M.search(self, base_dn, scope, deref_aliases, size_limit, time_limit, types_only, filter, attributes) local search_req, err = protocol.search_request( diff --git a/lib/resty/ldap/protocol.lua b/lib/resty/ldap/protocol.lua index ef25f56..3addb00 100644 --- a/lib/resty/ldap/protocol.lua +++ b/lib/resty/ldap/protocol.lua @@ -13,6 +13,7 @@ _M.ERROR_MSG = { [1] = "Initialization of LDAP library failed", [4] = "Size limit exceeded", [13] = "Confidentiality required", + [14] = "SASL bind in progress", [32] = "No such object", [34] = "Invalid DN", [49] = "The supplied credential is invalid" @@ -47,6 +48,19 @@ function _M.start_tls_request() end +function _M.sasl_bind_request(mechanism, credentials) + local sasl_body = asn1_encode(mechanism) + if credentials and #credentials > 0 then + sasl_body = sasl_body .. asn1_encode(credentials) + end + -- [3] CONSTRUCTED = SaslCredentials SEQUENCE in AuthenticationChoice + local sasl_auth = asn1_put_object(3, asn1.CLASS.CONTEXT_SPECIFIC, 1, sasl_body) + local bindReq = asn1_encode(3) .. asn1_encode("") .. sasl_auth + local ldapMsg = ldap_message(_M.APP_NO.BindRequest, bindReq) + return asn1_encode(ldapMsg, asn1.TAG.SEQUENCE) +end + + function _M.simple_bind_request(dn, password) local ldapAuth = asn1_put_object(0, asn1.CLASS.CONTEXT_SPECIFIC, 0, password or "") if not password then diff --git a/src/gssapi.rs b/src/gssapi.rs new file mode 100644 index 0000000..6cc6239 --- /dev/null +++ b/src/gssapi.rs @@ -0,0 +1,58 @@ +use libgssapi::{ + context::{ClientCtx, CtxFlags, SecurityContext}, + credential::{Cred, CredUsage}, + name::Name, + oid::GSS_NT_HOSTBASED_SERVICE, +}; +use mlua::prelude::*; + +pub struct GssapiCtx(ClientCtx); + +impl LuaUserData for GssapiCtx { + fn add_methods<'lua, M: LuaUserDataMethods<'lua, Self>>(methods: &mut M) { + // step(in_token_or_nil) -> out_token_or_nil, is_complete + methods.add_method_mut("step", |lua, this, in_token: Option| { + let in_bytes = in_token.map(|s| s.as_bytes().to_vec()); + let in_slice = in_bytes.as_deref(); + match this.0.step(in_slice, None) { + Err(e) => Err(LuaError::RuntimeError(format!("{}", e))), + Ok(None) => Ok((LuaValue::Nil, true)), + Ok(Some(tok)) => { + let complete = this.0.is_complete(); + Ok((LuaValue::String(lua.create_string(&*tok)?), complete)) + } + } + }); + + // wrap(plaintext) -> wrapped (integrity only, no encryption — SSF=0) + methods.add_method_mut("wrap", |lua, this, msg: LuaString| { + this.0 + .wrap(false, msg.as_bytes()) + .map(|b| lua.create_string(&*b)) + .map_err(|e| LuaError::RuntimeError(format!("{}", e))) + }); + + // unwrap(wrapped) -> plaintext + methods.add_method_mut("unwrap", |lua, this, msg: LuaString| { + this.0 + .unwrap(msg.as_bytes()) + .map(|b| lua.create_string(&*b)) + .map_err(|e| LuaError::RuntimeError(format!("{}", e))) + }); + } +} + +// gssapi_new("ldap@hostname") -> GssapiCtx userdata +pub fn new_ctx<'lua>(lua: &'lua Lua, service: LuaString<'lua>) -> LuaResult> { + let name = Name::new(service.as_bytes(), Some(&GSS_NT_HOSTBASED_SERVICE)) + .map_err(|e| LuaError::RuntimeError(format!("{}", e)))?; + let cred = Cred::acquire(None, None, CredUsage::Initiate, None) + .map_err(|e| LuaError::RuntimeError(format!("{}", e)))?; + let ctx = ClientCtx::new( + Some(cred), + name, + CtxFlags::GSS_C_MUTUAL_FLAG | CtxFlags::GSS_C_SEQUENCE_FLAG, + None, + ); + lua.create_userdata(GssapiCtx(ctx)) +} diff --git a/src/ldap_codec/decoder.rs b/src/ldap_codec/decoder.rs index ebd1f5e..7617ebb 100644 --- a/src/ldap_codec/decoder.rs +++ b/src/ldap_codec/decoder.rs @@ -43,6 +43,9 @@ pub fn decode<'lua>( "diagnostic_msg", bytes_to_string(resp.diagnostic_message).unwrap(), )?; + if let Some(creds) = resp.server_sasl_creds { + result.set("server_sasl_creds", lua.create_string(creds.as_ref())?)?; + } return Ok((LuaValue::Table(result), LuaValue::Nil)); } ProtocolOp::SearchResEntry(entry) => { diff --git a/src/lib.rs b/src/lib.rs index 32e52d6..b937bcc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ use ldap_codec::{decoder::decode, encoder::encode}; use mlua::prelude::{Lua, LuaResult, LuaTable}; +mod gssapi; mod ldap_codec; #[mlua::lua_module] @@ -9,6 +10,7 @@ fn rasn(lua: &Lua) -> LuaResult { exports.set("encode_ldap", lua.create_function(encode)?)?; exports.set("decode_ldap", lua.create_function(decode)?)?; + exports.set("gssapi_new", lua.create_function(gssapi::new_ctx)?)?; Ok(exports) }