diff --git a/resources/prosody-plugins/token/jwk.lib.lua b/resources/prosody-plugins/token/jwk.lib.lua new file mode 100644 index 0000000000..cf1aee84f3 --- /dev/null +++ b/resources/prosody-plugins/token/jwk.lib.lua @@ -0,0 +1,134 @@ +local basexx = require "basexx"; + +local M = {} + +-- Helper function to encode bytes to base64 +function base64_encode(bytes) + return basexx.to_base64(bytes) +end + +-- Pure Lua ASN.1 DER encoder (no external dependencies) +local ASN1 = {} + +-- Encode ASN.1 length field +function ASN1.encode_length(len) + if len < 128 then + return string.char(len) + elseif len < 256 then + return string.char(0x81, len) + elseif len < 65536 then + return string.char(0x82, math.floor(len / 256), len % 256) + else + local b1 = math.floor(len / 65536) + local b2 = math.floor((len % 65536) / 256) + local b3 = len % 256 + return string.char(0x83, b1, b2, b3) + end +end + +-- Encode ASN.1 INTEGER +function ASN1.encode_integer(bytes) + -- ASN.1 INTEGER tag is 0x02 + -- If the high bit is set, prepend 0x00 to indicate positive number + if bytes:byte(1) >= 0x80 then + bytes = string.char(0x00) .. bytes + end + return string.char(0x02) .. ASN1.encode_length(#bytes) .. bytes +end + +-- Encode ASN.1 SEQUENCE +function ASN1.encode_sequence(content) + -- ASN.1 SEQUENCE tag is 0x30 + return string.char(0x30) .. ASN1.encode_length(#content) .. content +end + +-- Encode ASN.1 BIT STRING +function ASN1.encode_bit_string(content) + -- ASN.1 BIT STRING tag is 0x03 + -- First byte indicates number of unused bits (0x00 for byte-aligned) + return string.char(0x03) .. ASN1.encode_length(#content + 1) .. string.char(0x00) .. content +end + +-- Encode ASN.1 OBJECT IDENTIFIER +function ASN1.encode_oid(oid_bytes) + -- ASN.1 OID tag is 0x06 + return string.char(0x06) .. ASN1.encode_length(#oid_bytes) .. oid_bytes +end + +-- Encode ASN.1 NULL +function ASN1.encode_null() + -- ASN.1 NULL tag is 0x05, length 0 + return string.char(0x05, 0x00) +end + +-- Convert DER to PEM format +function ASN1.der_to_pem(der, label) + label = label or "PUBLIC KEY" + local base64 = base64_encode(der) + + -- Break into 64-character lines + local lines = {} + for i = 1, #base64, 64 do + table.insert(lines, base64:sub(i, i + 63)) + end + + return "-----BEGIN " .. label .. "-----\n" .. + table.concat(lines, "\n") .. "\n" .. + "-----END " .. label .. "-----\n" +end + +-- Helper function to decode base64url +function base64url_decode(str) + -- Convert base64url to base64 + str = str:gsub('-', '+'):gsub('_', '/') + -- Add padding if needed + local padding = #str % 4 + if padding > 0 then + str = str .. string.rep('=', 4 - padding) + end + return basexx.from_base64(str) +end + +-- Helper function to convert JWK to PEM format +function M.jwk_to_pem(jwk) + -- Decode the modulus (n) and exponent (e) from base64url + local n_bytes = base64url_decode(jwk.n) + local e_bytes = base64url_decode(jwk.e) + + -- Build RSA public key structure + -- RSAPublicKey ::= SEQUENCE { + -- modulus INTEGER, -- n + -- publicExponent INTEGER -- e + -- } + local modulus_asn1 = ASN1.encode_integer(n_bytes) + local exponent_asn1 = ASN1.encode_integer(e_bytes) + local rsa_pubkey = ASN1.encode_sequence(modulus_asn1 .. exponent_asn1) + + -- Build SubjectPublicKeyInfo structure + -- SubjectPublicKeyInfo ::= SEQUENCE { + -- algorithm AlgorithmIdentifier, + -- subjectPublicKey BIT STRING + -- } + + -- RSA OID: 1.2.840.113549.1.1.1 (rsaEncryption) + -- Encoded as: 06 09 2A 86 48 86 F7 0D 01 01 01 + local rsa_oid = string.char(0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01) + local rsa_oid_encoded = ASN1.encode_oid(rsa_oid) + + -- AlgorithmIdentifier ::= SEQUENCE { + -- algorithm OBJECT IDENTIFIER, + -- parameters NULL + -- } + local algorithm_id = ASN1.encode_sequence(rsa_oid_encoded .. ASN1.encode_null()) + + -- Wrap the RSA public key in a BIT STRING + local subject_public_key = ASN1.encode_bit_string(rsa_pubkey) + + -- Final SubjectPublicKeyInfo + local spki = ASN1.encode_sequence(algorithm_id .. subject_public_key) + + -- Convert to PEM format + return ASN1.der_to_pem(spki, "PUBLIC KEY") +end + +return M diff --git a/resources/prosody-plugins/token/util.lib.lua b/resources/prosody-plugins/token/util.lib.lua index 489b37f34f..88037ad827 100644 --- a/resources/prosody-plugins/token/util.lib.lua +++ b/resources/prosody-plugins/token/util.lib.lua @@ -5,6 +5,7 @@ local basexx = require "basexx"; local have_async, async = pcall(require, "util.async"); local hex = require "util.hex"; local jwt = module:require "luajwtjitsi"; +local jwk_to_pem = module:require "token/jwk".jwk_to_pem; local jid = require "util.jid"; local json_safe = require "cjson.safe"; local path = require "util.paths"; @@ -111,14 +112,14 @@ function Util.new(module) return nil; end - if self.appSecret == nil and self.asapKeyServer == nil then - module:log("error", "'app_secret' or 'asap_key_server' must be specified"); + if self.appSecret == nil and self.asapKeyServer == nil and self.cacheKeysUrl == nil then + module:log("error", "'app_secret', 'asap_key_server or 'cacheKeysUrl' must be specified"); return nil; end -- Set defaults for signature algorithm if self.signatureAlgorithm == nil then - if self.asapKeyServer ~= nil then + if self.asapKeyServer ~= nil or self.cacheKeysUrl then self.signatureAlgorithm = "RS256" elseif self.appSecret ~= nil then self.signatureAlgorithm = "HS256" @@ -133,7 +134,7 @@ function Util.new(module) self.requireRoomClaim = module:get_option_boolean('asap_require_room_claim', true); - if self.asapKeyServer and not have_async then + if (self.asapKeyServer or self.cacheKeysUrl) and not have_async then module:log("error", "requires a version of Prosody with util.async"); return nil; end @@ -148,7 +149,18 @@ function Util.new(module) local keys_to_delete = table_shallow_copy(self.cachedKeys); -- Let's convert any certificate to public key for k, v in pairs(cjson_safe.decode(content)) do - if starts_with(v, '-----BEGIN CERTIFICATE-----') then + -- JWKS format + if k == "keys" and type(v) == "table" then + for _, key in ipairs(v) do + if key.kid then + self.cachedKeys[key.kid] = jwk_to_pem(key); + + -- do not clean this key if it already exists + keys_to_delete[key.kid] = nil; + end + end + -- direct PEM mapping (Firebase) + elseif starts_with(v, '-----BEGIN CERTIFICATE-----') then self.cachedKeys[k] = ssl.loadcertificate(v):pubkey(); -- do not clean this key if it already exists keys_to_delete[k] = nil; @@ -263,7 +275,7 @@ function Util:process_and_verify_token(session) -- We're using an public key stored in the session -- module:log("debug","Public key was found on the session"); key = session.public_key; - elseif self.asapKeyServer and session.auth_token ~= nil then + elseif (self.asapKeyServer or self.cacheKeysUrl) and session.auth_token ~= nil then -- We're fetching an public key from an ASAP server local dotFirst = session.auth_token:find("%."); if not dotFirst then return false, "not-allowed", "Invalid token" end