Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
166 changes: 162 additions & 4 deletions lib/resty/ldap/client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -88,18 +89,18 @@ 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
local sock = tcp()

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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions lib/resty/ldap/protocol.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions src/gssapi.rs
Original file line number Diff line number Diff line change
@@ -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<LuaString>| {
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<LuaAnyUserData<'lua>> {
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))
}
3 changes: 3 additions & 0 deletions src/ldap_codec/decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -9,6 +10,7 @@ fn rasn(lua: &Lua) -> LuaResult<LuaTable> {

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