Skip to content
Merged
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
298 changes: 278 additions & 20 deletions lib/resty/ldap/asn1.lua
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
local ffi = require("ffi")
local C = ffi.C
local ffi_new = ffi.new
local ffi_string = ffi.string
local string_char = string.char
local new_tab = require("resty.core.base").new_tab
local ffi = require("ffi")
local C = ffi.C
local ffi_new = ffi.new
local ffi_string = ffi.string
local ffi_cast = ffi.cast
local band = require("bit").band
local string_byte = string.byte
local string_char = string.char
local string_format = string.format
local string_sub = string.sub
local new_tab = require("resty.core.base").new_tab

local ucharpp = ffi_new("unsigned char*[1]")
local charpp = ffi_new("char*[1]")
local cucharpp = ffi_new("const unsigned char*[1]")


ffi.cdef [[
Expand All @@ -22,6 +28,9 @@ ffi.cdef [[
void ASN1_INTEGER_free(ASN1_INTEGER *a);
void ASN1_STRING_free(ASN1_STRING *a);

// frees the buffer i2d_* allocates when *pp is NULL
void CRYPTO_free(void *ptr, const char *file, int line);

long ASN1_INTEGER_get(const ASN1_INTEGER *a);
long ASN1_ENUMERATED_get(const ASN1_ENUMERATED *a);

Expand Down Expand Up @@ -76,56 +85,104 @@ local TAG = {
_M.TAG = TAG


-- ASN1_put_object emits at most 11 header bytes (tag + long-form length)
local hdrbuf = ffi_new("unsigned char[16]")

-- length is a C int; guard against FFI-boundary truncation/clamping
local INT_MAX = 2147483647

local function asn1_put_object(tag, class, constructed, data, len)
len = type(data) == "string" and #data or len or 0
if len < 0 then
-- a non-string payload would coerce via tostring() with len 0, emitting a
-- header that disagrees with the bytes that follow it
if data ~= nil and type(data) ~= "string" then
return nil, "invalid object payload"
end
if data ~= nil then
len = #data
elseif len == nil then
len = 0
end
if type(len) ~= "number" or len % 1 ~= 0 or len < 0 or len > INT_MAX then
return nil, "invalid object length"
end

local outbuf = ffi_new("unsigned char[?]", len)
ucharpp[0] = outbuf

-- read back exactly the header bytes written; a strlen-based read would
-- truncate a length octet containing 0x00 (e.g. 256 -> 82 01 00)
ucharpp[0] = hdrbuf
C.ASN1_put_object(ucharpp, constructed, len, tag, class)
local header = ffi_string(hdrbuf, ucharpp[0] - hdrbuf)
if not data then
return ffi_string(outbuf)
return header
end
return ffi_string(outbuf) .. data
return header .. data
end

_M.put_object = asn1_put_object


-- 2^53: largest magnitude a Lua 5.1 double holds exactly; matches the decoder
local INT_EXACT = 9007199254740992

local encode
do
local encoder = new_tab(0, 3)

-- refuse values the C long conversion would truncate/clamp; the integrality
-- test also rejects NaN and +/-inf ((0/0)%1 and (1/0)%1 are NaN)
local function integer_encodable(val)
return type(val) == "number" and val % 1 == 0
and val <= INT_EXACT and val >= -INT_EXACT
end

-- i2d with *pp == NULL allocates a buffer we own; copy it out then free it,
-- else a worker leaks one encoding per encode. charpp is module-level.
local function take_i2d_output(ret)
if ret <= 0 then
charpp[0] = nil
return nil, "failed to encode ASN.1 value"
end
local out = ffi_string(charpp[0], ret)
C.CRYPTO_free(charpp[0], "asn1.lua", 0)
charpp[0] = nil
return out
end

-- Integer
encoder[TAG.INTEGER] = function(val)
if not integer_encodable(val) then
return nil, "INTEGER value is not an exactly representable integer"
end
local typ = C.ASN1_INTEGER_new()
C.ASN1_INTEGER_set(typ, val)
charpp[0] = nil
local ret = C.i2d_ASN1_INTEGER(typ, charpp)
C.ASN1_INTEGER_free(typ)
return ffi_string(charpp[0], ret)
return take_i2d_output(ret)
end

-- Octet String
encoder[TAG.OCTET_STRING] = function(val)
if type(val) ~= "string" then
return nil, "OCTET STRING value must be a string"
end
local typ = C.ASN1_OCTET_STRING_new()
C.ASN1_STRING_set(typ, val, #val)
charpp[0] = nil
local ret = C.i2d_ASN1_OCTET_STRING(typ, charpp)
C.ASN1_STRING_free(typ)
return ffi_string(charpp[0], ret)
return take_i2d_output(ret)
end

encoder[TAG.ENUMERATED] = function(val)
if not integer_encodable(val) then
return nil, "ENUMERATED value is not an exactly representable integer"
end
local typ = C.ASN1_ENUMERATED_new()
C.ASN1_ENUMERATED_set(typ, val)
charpp[0] = nil
local ret = C.i2d_ASN1_ENUMERATED(typ, charpp)
C.ASN1_INTEGER_free(typ)
return ffi_string(charpp[0], ret)
C.ASN1_INTEGER_free(typ) -- shares the ASN1_STRING struct
return take_i2d_output(ret)
end

encoder[TAG.SEQUENCE] = function(val)
Expand All @@ -137,8 +194,13 @@ do
end

encoder[TAG.BOOLEAN] = function(val)
-- reject non-booleans: under Lua truthiness 0 is true, so a caller's
-- 0-for-false would silently encode as TRUE
if type(val) ~= "boolean" then
return nil, "BOOLEAN value must be a boolean"
end
-- tag(BOOLEAN), length(1), value(TRUE: 0xFF, FALSE: 0)
return string_char(1, 1, val and 0xff or 0)
return string_char(TAG.BOOLEAN, 1, val and 0xff or 0)
end

function encode(val, tag)
Expand All @@ -153,12 +215,208 @@ do
end
end

if encoder[tag] then
return encoder[tag](val)
local enc = encoder[tag]
if not enc then
-- return an error, not a bare nil callers would concatenate
return nil, string_format("no encoder for ASN.1 tag %s (value of type %s)",
tostring(tag), type(val))
end
return enc(val)
end
end
_M.encode = encode


local MAX_DECODE_DEPTH = 100

local asn1_get_object
do
local lenp = ffi_new("long[1]")
local tagp = ffi_new("int[1]")
local classp = ffi_new("int[1]")
local strpp = ffi_new("const unsigned char*[1]")

function asn1_get_object(der, start, stop)
start = start or 0
stop = stop or #der
-- validate offsets before they feed pointer arithmetic and omax below
if type(start) ~= "number" or start < 0 or start % 1 ~= 0
or type(stop) ~= "number" or stop % 1 ~= 0 then
return nil, "invalid offset"
end
if stop <= start or stop > #der then
return nil, "invalid offset"
end

local s_der = ffi_cast("const unsigned char *", der)
strpp[0] = s_der + start

local ret = C.ASN1_get_object(strpp, lenp, tagp, classp, stop - start)
-- 0x80 signals an encoding error (incl. indefinite/too-long forms)
if band(ret, 0x80) == 0x80 or band(ret, 0x01) == 0x01 then
return nil, "invalid BER: bad tag/length encoding"
end

-- X.690 s8.1.2.2/s8.1.2.4.2: tags 0..30 must use the low-tag-number form
-- and forbid a leading 0x80 padding octet. ASN1_get_object normalises the
-- non-canonical forms to the same tag, so guard them here.
local id = string_byte(der, start + 1)
if band(id, 0x1f) == 0x1f then
if tagp[0] < 31 then
return nil, "invalid BER: tag <= 30 must use the low-tag-number form"
end
if band(string_byte(der, start + 2) or 0, 0x7f) == 0 then
return nil, "invalid BER: non-canonical tag number padding"
end
end

local content_off = strpp[0] - s_der
return {
tag = tagp[0],
class = classp[0],
len = tonumber(lenp[0]),
offset = content_off, -- 0-indexed content start
hl = content_off - start, -- header length (tag + length octets)
cons = band(ret, 0x20) == 0x20,
}
end
end
_M.get_object = asn1_get_object


local decode
do
local decoder = new_tab(0, 5)

-- required form per universal type (X.690 s8.9.1/s8.11.1, RFC 4511 s5.1);
-- the wrong form is a BER-smuggling vector against auth parsers
local CONSTRUCTED_FORM = {
[TAG.INTEGER] = false,
[TAG.OCTET_STRING] = false,
[TAG.ENUMERATED] = false,
[TAG.SEQUENCE] = true,
[TAG.SET] = true,
}

local TAG_NAME = {
[TAG.INTEGER] = "INTEGER",
[TAG.OCTET_STRING] = "OCTET STRING",
[TAG.ENUMERATED] = "ENUMERATED",
[TAG.SEQUENCE] = "SEQUENCE",
[TAG.SET] = "SET",
}

-- refuse magnitudes a Lua double can't hold exactly; RFC 4511 caps LDAP
-- integers at 2^31-1 so nothing legitimate is lost. Compared on the int64
-- cdata, before tonumber() rounds.
local INT_EXACT_MAX = 9007199254740992LL -- 2^53

local function exact_number(v, what)
if v > INT_EXACT_MAX or v < -INT_EXACT_MAX then
return nil, what .. " out of representable range"
end
return tonumber(v)
end

-- OCTET STRING: slice raw content bytes; binary/NUL-safe (no d2i, no strlen)
decoder[TAG.OCTET_STRING] = function(der, _, obj)
return string_sub(der, obj.offset + 1, obj.offset + obj.len)
end

-- d2i parses a full TLV, so offset is the TLV start. >8 content octets
-- cannot fit a C long, so the magnitude is out of range.
decoder[TAG.INTEGER] = function(der, offset, obj)
if obj.len > 8 then
return nil, "INTEGER out of representable range"
end
cucharpp[0] = ffi_cast("const unsigned char *", der) + offset
local typ = C.d2i_ASN1_INTEGER(nil, cucharpp, obj.hl + obj.len)
if typ == nil then
return nil, "invalid INTEGER encoding"
end
local v = C.ASN1_INTEGER_get(typ)
C.ASN1_INTEGER_free(typ)
return exact_number(v, "INTEGER")
end

decoder[TAG.ENUMERATED] = function(der, offset, obj)
if obj.len > 8 then
return nil, "ENUMERATED out of representable range"
end
cucharpp[0] = ffi_cast("const unsigned char *", der) + offset
local typ = C.d2i_ASN1_ENUMERATED(nil, cucharpp, obj.hl + obj.len)
if typ == nil then
return nil, "invalid ENUMERATED encoding"
end
local v = C.ASN1_ENUMERATED_get(typ)
C.ASN1_INTEGER_free(typ) -- shares the ASN1_STRING struct
return exact_number(v, "ENUMERATED")
end

local function decode_children(der, _, obj, depth)
local stop = obj.offset + obj.len
local pos = obj.offset
local values = {}
local n = 0
while pos < stop do
local value, err
-- bound to the parent's content so an overrunning child cannot
-- absorb a sibling's bytes without an error
pos, value, err = decode(der, pos, stop, depth + 1)
if err then
return nil, err
end
-- decode() never returns a nil value with a nil err, so no holes
n = n + 1
values[n] = value
end
return values
end

decoder[TAG.SEQUENCE] = decode_children
decoder[TAG.SET] = decode_children

-- offset is 0-indexed; stop bounds the TLV to its parent, defaulting to the
-- whole buffer. Returns next_offset, value, err.
function decode(der, offset, stop, depth)
offset = offset or 0
depth = depth or 0
if depth > MAX_DECODE_DEPTH then
return nil, nil, "max decode depth exceeded"
end
local obj, err = asn1_get_object(der, offset, stop)
if not obj then
return nil, nil, err
end

-- dispatch on class + tag + form; tag alone would confuse
-- context-specific [4]/[16]/[17] with universal types
if obj.class ~= CLASS.UNIVERSAL then
return nil, nil, string_format("unsupported element: class 0x%02x tag %d",
obj.class, obj.tag)
end
local d = decoder[obj.tag]
if not d then
-- error, not a silent nil ("absent" vs "present but unknown")
return nil, nil, string_format("unsupported ASN.1 tag %d", obj.tag)
end
local want_cons = CONSTRUCTED_FORM[obj.tag]
if obj.cons ~= want_cons then
return nil, nil, string_format("%s must be %s", TAG_NAME[obj.tag],
want_cons and "constructed" or "primitive")
end

local value, derr = d(der, offset, obj, depth)
if derr then
return nil, nil, derr
end
if value == nil then
return nil, nil, string_format("%s produced no value", TAG_NAME[obj.tag])
end
return obj.offset + obj.len, value
end
end
_M.decode = decode


return _M
Loading