从DPoP协议的理论到OpenResty的ES256签名实践,我们如何与GPT、Cursor并肩作战。
四个月前,谷雨开源SaaS平台(G2rain)重新出发。面对全新的开始,我和搭档Alpha投入了大量时间进行架构设计和基础研究。作为长期深耕Java后端开发的我们,面临一个必然的挑战: 一个SaaS平台必须拥有强大的前端交互能力 。
这个前端不仅承载着用户界面,更承载着谷雨最核心的理念之一—— 应用化 。我们的设想是:通过前后端彻底分离,微服务层提供平台核心能力,而应用层(理想情况下只需前端)专注于客户交互、授权管控和计费计量。这样,每个业务应用可以独立开发、部署、升级,实现真正的持续交付。
这个理念在三年前的第一版谷雨中就萌芽了。但当时一直有个困惑萦绕不去: 安全性如何保证 ?一个纯前端应用,如何确保每个请求携带的身份令牌(token)准确无误?如何防止请求参数被篡改?这个问题我和Alpha讨论了无数次,甚至一度怀疑在HTTPS已经普及的今天,传统的IAM授权和Session机制是否已经足够。
直到人工智能给我们指明了一条全新的道路。
一次偶然的技术讨论中,Alpha向GPT提出了我们的安全困境。在众多网络安全方案中,GPT精准地为我们“捞出”了一个协议: DPoP (Demonstrating Proof-of-Possession)。
DPoP的核心思想:将令牌与特定的客户端密钥对绑定。客户端在请求时不仅要提供Access Token,还必须附加一个密码学证明,证明它持有与令牌关联的私钥。这样,即使令牌泄露,攻击者也无法使用它。
这与我们的需求完美契合!我们需要的不正是“证明此请求来自合法的前端应用本身”吗?
基于DPoP的理念,我们设计出了“谷雨SaaS平台安全交互规范”:
七步安全交互的核心逻辑 :
这个设计确保了:即使Token被截获,没有对应的私钥也无法使用;即使请求被拦截,没有正确的签名也无法伪造。
理论很美好,但实践起来却是另一番景象。当我们在OpenResty网关中实现ES256签名验证时,遇到了意想不到的困境。
问题在于:OpenResty的默认安装包并不包含现成的椭圆曲线加密库。我们开始了一场与AI工具的深度协作:
然而,我们很快陷入了一个“依赖怪圈”:
为了解决A,需要安装B;安装B时,发现需要C的特定版本;编译C时,又需要A的某个功能……如此循环,无休无止。
更关键的是,我们最终锚定的那个 较新的、专门为OpenResty优化的Lua加密库luoss-rel-20250929 ,其文档和API 尚未被AI训练数据收录 。这意味着:
这是一个重要的发现: AI的能力受限于其训练数据的时效性和覆盖面 。对于前沿的、小众的、刚发布的技术文档,AI可能一无所知。
认识到AI的局限后,我们调整了策略,形成了全新的“人机协同”工作流:
我花了整整一个下午,仔细阅读那个新库的英文文档。虽然过程缓慢,但我逐渐理解了:
我把文档的关键部分、项目的上下文、以及具体要解决的问题,打包提交给Cursor:
text
项目背景:我们需要在OpenResty的Lua脚本中实现ES256签名验证已选库:luoss-rel-20250929说明文档如下:pkey.new(string[, format])Initializes a new pkey object from the PEM- or DER-encoded key in string. format defaults to“*”, which means to automatically test the input encoding. If format is explicitly “PEM” or“DER”, then only that decoding format is used.On failure throws an error.pkey.new{ . . . }Generates a new pkey object according to the specified parameters.field type:default description.type string:RSA public key algorithm—“RSA”, “DSA”, “EC”, “DH”, or an internal OpenSSLidentifier of a subclass of one of those basic types.bits number:1024 private key size.exp number:65537 RSA exponent.generator number:2 Diffie-Hellman generator.dhparam string PEM encoded string with precomputed DH parameters.curve string:prime192v1 for elliptic curve keys, the OpenSSL string identifier of the curveThe DH parameters “dhparam” will be generated on the fly, “bits” wide. This is a slow process,and especially for larger sizes, you would precompute those; for example: “openssl dhparam -2 -outdh-2048.pem -outform PEM 2048”. Using the field “dhparam” overrides the “bits” field.具体需求:实现一个Lua模块,实现密钥的生成,存储,签名和验证的方法,供sign_api.lua调用
这一次,Cursor的表现完全不同了。它基于我提供的准确信息,结合对整个项目代码结构的理解,生成了:
sign.lua
local cjson = require "cjson.safe"local openssl = require "openssl"local pkey_lib = require "openssl.pkey"local digest_lib = require "openssl.digest"local b64 = require "ngx.base64"local _M = {} -- Base64URL helper (binary input)local function b64url(bin) if not bin then return nil end local s = ngx.encode_base64(bin) return s:gsub('+','-'):gsub('/','_'):gsub('=','')end -- sha256 (returns binary)local function sha256_bin(data) local d = digest_lib.new("sha256") d:update(data or "") return d:final()end -- 解析DER长度local function parse_len(data, i) local b = data:byte(i) if not b then return nil, "short input in parse_len" end if b < 0x80 then return b, i + 1 end local n = b - 0x80 if n <= 0 or n > 4 then return nil, "unsupported length bytes" end local len = 0 for k = 1, n do local bb = data:byte(i + k) if not bb then return nil, "short input in parse_len body" end len = len * 256 + bb end return len, i + n + 1end -- 从SPKI DER格式中提取EC点(X,Y)local function extract_ec_point_from_spki(der) if not der or #der < 1 then return nil, "empty der" end -- 确认开头是SEQUENCE标志 (0x30) local pos = 1 if der:byte(pos) ~= 0x30 then return nil, "not SEQUENCE" end -- 解析SEQUENCE长度 local top_len, next_pos = parse_len(der, pos + 1) if not next_pos then return nil, top_len end pos = next_pos -- 确认第二个SEQUENCE标志 (0x30) if der:byte(pos) ~= 0x30 then return nil, "AlgorithmIdentifier not SEQUENCE" end -- 解析算法标识符的长度 local alg_len, alg_next = parse_len(der, pos + 1) if not alg_next then return nil, alg_len end pos = alg_next + alg_len - 1 -- 确认BIT STRING标志 (0x03) if der:byte(pos + 1) ~= 0x03 then return nil, "BIT STRING not found" end -- 解析BIT STRING长度 local bit_len, bit_next = parse_len(der, pos + 2) if not bit_next then return nil, bit_len end local unused = der:byte(bit_next) if unused ~= 0 then return nil, "unsupported unused bits" end -- 提取EC点数据 local point_start = bit_next + 1 local point = der:sub(point_start, point_start + bit_len - 2) if not point or #point < 1 then return nil, "empty EC point" end -- 确认是未压缩的EC点 (0x04) if point:byte(1) ~= 0x04 then return nil, "not uncompressed EC point" end -- 提取X和Y坐标 local x = point:sub(2, 33) local y = point:sub(34, 65) -- 校验X和Y的长度是否正确 if #x ~= 32 or #y ~= 32 then return nil, "unexpected coordinate length" end return x, yend -- Convert DER ECDSA signature -> raw R||S (64 bytes)local function der_to_rs(der) if not der or #der < 8 then return nil, "der too short" end local pos = 1 if der:byte(pos) ~= 0x30 then return nil, "not DER SEQUENCE" end pos = pos + 1 local len = der:byte(pos); pos = pos + 1 if len >= 0x80 then local n = len - 0x80 len = 0 for i = 1, n do len = len * 256 + der:byte(pos); pos = pos + 1 end end if der:byte(pos) ~= 0x02 then return nil, "no R" end pos = pos + 1 local rlen = der:byte(pos); pos = pos + 1 if der:byte(pos) == 0x00 then pos = pos + 1; rlen = rlen - 1 end local r = der:sub(pos, pos + rlen - 1) pos = pos + rlen if der:byte(pos) ~= 0x02 then return nil, "no S" end pos = pos + 1 local slen = der:byte(pos); pos = pos + 1 if der:byte(pos) == 0x00 then pos = pos + 1; slen = slen - 1 end local s = der:sub(pos, pos + slen - 1) local function to32(v) if #v < 32 then return string.rep("\0", 32 - #v) .. v end if #v > 32 then return v:sub(#v - 31) end return v end return to32(r) .. to32(s)end -- Generate JWK from public DERlocal function der_to_jwk(der) if not der then ngx.log(ngx.ERR, "no public der") return nil, "no public der" end ngx.log(ngx.ERR, "Attempting to load public key from DER") -- try to load pkey from DER using setPublicKey local ok, key = pcall(function() local pkey = pkey_lib.new() pkey:setPublicKey(der, "DER") -- Correct method to set the public key return pkey end) if not ok or not key then ngx.log(ngx.ERR, "Failed to load public key. Error: ", tostring(key)) return nil, "failed to load public key: " .. tostring(key) end ngx.log(ngx.ERR, "Successfully loaded public key") -- try extracting EC point from the DER local x, y = extract_ec_point_from_spki(der) if not x or not y then ngx.log(ngx.ERR, "Failed to extract EC point from SPKI DER") return nil, "failed to extract EC point" end ngx.log(ngx.ERR, "Successfully extracted EC point from DER") return { kty = "EC", crv = "P-256", x = b64url(x), y = b64url(y) }end -- ES256 sign: returns base64url(R||S)local function sign_es256(private_der, signing_input) if not private_der then return nil, "no private der" end ngx.log(ngx.ERR, "Loading private key from DER") -- load private key from DER using setPrivateKey local ok, pkey_obj = pcall(function() local pkey = pkey_lib.new() pkey:setPrivateKey(private_der, "DER") -- Correct method to set the private key return pkey end) if not ok or not pkey_obj then ngx.log(ngx.ERR, "Failed to load private key: ", tostring(pkey_obj)) return nil, "failed to load private key: " .. tostring(pkey_obj) end ngx.log(ngx.ERR, "Successfully loaded private key") local hash = pkey_obj:getDefaultDigestName() ngx.log(ngx.ERR, "DefaultDigestName: ", tostring(hash)) local md_ctx = digest_lib.new(hash) -- Initialize the SHA256 context md_ctx:update(signing_input) -- Update the context with the data to sign local sig = pkey_obj:sign(md_ctx) if not sig then ngx.log(ngx.ERR, "Failed to sign with private key: ", tostring(sig)) return nil, "sign failed: " .. tostring(sig) end ngx.log(ngx.ERR, "Successfully generated signature") if string.byte(sig, 1) == 0x30 then local rs, err = der_to_rs(sig) if not rs then return nil, "der_to_rs failed: " .. tostring(err) end sig = rs end return b64url(sig)end -- calculate pha = base64url(SHA256(body))function _M.calculate_pha(body) body = body or "" return b64url(sha256_bin(body))end local function encode_json_b64url(obj) return b64url(cjson.encode(obj))end -- generate DPoP JWTfunction _M.generate_jwt(payload, private_der, public_der, key_id) private_der = private_der or "" public_der = public_der or "" ngx.log(ngx.ERR, "Generating JWT...") local jwk, err = der_to_jwk(public_der) if not jwk then ngx.log(ngx.ERR, "der_to_jwk failed: ", err) return nil, "der_to_jwk failed: " .. tostring(err) end ngx.log(ngx.ERR, "JWK: ", cjson.encode(jwk)) local kid = key_id or compute_kid(jwk) local header = { typ = "dpop+jwt", alg = "ES256", ph_alg = "SHA-256", jwk = jwk, kid = kid } local h = encode_json_b64url(header) local p = encode_json_b64url(payload) local signing_input = h .. "." .. p ngx.log(ngx.ERR, "Signing input: ", signing_input) local signature, serr = sign_es256(private_der, signing_input) if not signature then ngx.log(ngx.ERR, "Failed to sign JWT: ", serr) return nil, serr end return signing_input .. "." .. signatureend -- generate with config active keyfunction _M.generate_jwt_with_config(payload) local config = require "config" local key = config.get_active_key() if not key then return nil, "no active key" end return _M.generate_jwt( payload, key["private-key"], key["public-key"], key["key-id"] )end return _M
config.lua
-- config.lua-- 从文件加载密钥配置信息 local _M = {} -- 密钥文件路径(相对于 lua 目录)local KEY_BASE_PATH = "/usr/local/openresty/nginx/lua/keys"local PUBLIC_KEY_FILE = KEY_BASE_PATH .. "/public-key.der" -- 修改为DER格式local PRIVATE_KEY_FILE = KEY_BASE_PATH .. "/private-key.der" -- 修改为DER格式 -- 读取文件内容local function read_file(file_path) local file, err = io.open(file_path, "rb") -- 以二进制模式读取文件 if not file then ngx.log(ngx.ERR, "Failed to open file: ", file_path, " Error: ", err) return nil end local content = file:read("*all") file:close() if content then ngx.log(ngx.ERR, "Successfully read file: ", file_path) else ngx.log(ngx.ERR, "Failed to read content from file: ", file_path) end return contentend -- 加载密钥配置local function load_keys() local public_key = read_file(PUBLIC_KEY_FILE) local private_key = read_file(PRIVATE_KEY_FILE) if not public_key or not private_key then ngx.log(ngx.ERR, "Failed to load keys from files") return nil end -- 从环境变量读取 applicationCode,如果没有则使用默认值 -- 注意:在 OpenResty 中,os.getenv 可能不可用,可以通过配置文件或 nginx 变量提供 local application_code = "g2rain-main-shell" local ok, env_value = pcall(function() return os.getenv("APPLICATION_CODE") end) if ok and env_value then application_code = env_value end -- 密钥配置(从 application.yml 获取的其他信息) return { { ["key-id"] = "yEMzeGLlhMpK5GxQKP5Fhg7JH9eALB7BK2BkadTOUxw", algorithm = "ES256", active = true, applicationCode = application_code, ["public-key"] = public_key, ["private-key"] = private_key } }end -- 缓存密钥配置(避免每次调用都读取文件)local cached_keys = nil -- 获取活动的密钥function _M.get_active_key() -- 如果缓存不存在,加载密钥 if not cached_keys then cached_keys = load_keys() if not cached_keys then ngx.log(ngx.ERR, "Failed to load keys from files.") return nil end end -- 查找活动的密钥 for _, key in ipairs(cached_keys) do if key.active then return key end end ngx.log(ngx.ERR, "No active key found.") return nilend -- 重新加载密钥(用于密钥轮换)function _M.reload_keys() cached_keys = nil return _M.get_active_key() ~= nilend -- 获取 DER 格式的公钥function _M.get_public_key_der() local public_key = _M.get_active_key()["public-key"] if not public_key then ngx.log(ngx.ERR, "No public key found in active key configuration.") return nil end ngx.log(ngx.ERR, "Returning public key (DER format).") return public_keyend -- 获取 DER 格式的私钥function _M.get_private_key_der() local private_key = _M.get_active_key()["private-key"] if not private_key then ngx.log(ngx.ERR, "No private key found in active key configuration.") return nil end ngx.log(ngx.ERR, "Returning private key (DER format).") return private_keyend return _M
sign_api.lua
-- sign_api.lua-- 接收 JSON 入参,使用 ES256 算法生成 JWT 签名 local cjson = require "cjson"local config = require "config"local sign = require "sign" -- 主处理函数local function handle_request() -- 设置响应头 ngx.header.content_type = "application/json; charset=utf-8" -- 只接受 POST 请求 if ngx.var.request_method ~= "POST" then ngx.status = ngx.HTTP_METHOD_NOT_ALLOWED ngx.say(cjson.encode({ error = "Method not allowed", message = "Only POST method is supported" })) return end -- 获取 URL 中的 jti 参数 local jti = ngx.var.arg_jti if not jti or jti == "" then ngx.status = ngx.HTTP_BAD_REQUEST ngx.say(cjson.encode({ error = "Bad request", message = "Missing or invalid 'jti' parameter" })) ngx.log(ngx.ERR, "Missing or invalid 'jti' parameter") return end -- 读取请求体 ngx.req.read_body() local body = ngx.req.get_body_data() if not body or body == "" then ngx.status = ngx.HTTP_BAD_REQUEST ngx.say(cjson.encode({ error = "Bad request", message = "Request body is required" })) return end -- 解析 JSON local args, err = cjson.decode(body) if not args then ngx.status = ngx.HTTP_BAD_REQUEST ngx.say(cjson.encode({ error = "Bad request", message = "Invalid JSON: " .. (err or "unknown error") })) return end -- 验证必要参数 if not args.grantType or not args.code then ngx.status = ngx.HTTP_BAD_REQUEST ngx.say(cjson.encode({ error = "Bad request", message = "Missing required parameters: grantType and code" })) ngx.log(ngx.ERR, "Missing parameters: grantType=" .. tostring(args.grantType) .. ", code=" .. tostring(args.code)) return end -- 获取活动的密钥配置 local key_config = config.get_active_key() if not key_config then ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR ngx.say(cjson.encode({ error = "Internal server error", message = "No active key found in configuration" })) ngx.log(ngx.ERR, "Failed to load active key configuration") return end -- 计算 pha (Payload Hash Algorithm) local pha = sign.calculate_pha(body) -- 构建 JWT Payload(参考 jwt.util.ts 的 DpopPayload 格式) local current_time = ngx.time() local payload = { htu = "/auth/token", -- HTTP URI htm = "POST", -- HTTP Method acd = key_config.applicationCode, -- Application Code pha = pha, -- Payload Hash Algorithm jti = jti, -- 请求id iat = current_time, -- 签发时间(issued at),对应 setIssuedAt() exp = current_time + 300 -- 过期时间(5分钟后),对应 setExpirationTime('5m') } -- 生成 JWT local jwt, err = sign.generate_jwt(payload, key_config["private-key"], key_config["public-key"], key_config["key-id"]) if not jwt then ngx.log(ngx.ERR, "Failed to generate JWT: ", err) -- 添加日志 ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR ngx.say(cjson.encode({ error = "Internal server error", message = "Failed to generate JWT: " .. (err or "unknown error") })) return end -- 返回结果 ngx.status = ngx.HTTP_OK ngx.header["Access-Control-Allow-Origin"] = "*" ngx.header["Access-Control-Allow-Methods"] = "POST" ngx.header["Access-Control-Allow-Headers"] = "Content-Type" ngx.say(cjson.encode({ token = jwt }))end -- 执行主处理函数local ok, err = pcall(handle_request)if not ok then ngx.log(ngx.ERR, "Error in sign_api.lua: ", err) ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR ngx.header.content_type = "application/json" ngx.say(cjson.encode({ error = "Internal server error", message = "An unexpected error occurred" }))end
这个代码不仅语法正确,而且:
经过这次实践,我们形成了清晰的“人机协作”开发哲学:
如今,谷雨SaaS平台的DPoP安全架构已经贯通。这套方案不仅解决了前端应用化的安全难题,更为平台的开放生态奠定了基础:
更令人兴奋的是,我们找到了一条高效的人机协作路径。在这个过程中,AI不是替代者,而是真正的“搭档”——它放大了我们的能力边界,让我们能专注于更高层次的设计和决策。
开源SaaS平台的未来,是开放的、安全的、智能的。在谷雨平台的下一阶段,我们将继续探索AI在测试生成、文档编写、性能优化等更多场景的应用。同时,我们也期待与更多开发者一起,共同探索AI时代的新型开发模式。
思考题 :在你的开发实践中,是否遇到过类似的“AI边界”时刻?你是如何与AI工具协同解决复杂技术问题的?欢迎在评论区分享你的经验。
谷雨开源SaaS平台正在招募早期贡献者,如果你对微服务架构、SaaS中台、或SaaS垂直领域能力(如:CMD, CRM等)感兴趣,欢迎访问我们的GitHub仓库 https://github.com/g2rain/g2rain 或通过公众号“谷雨开源SaaS”加入讨论。