从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平台安全交互规范”:

七步安全交互的核心逻辑

  1. 客户端密钥生成:每个用户会话开始时,浏览器生成唯一的ECDSA密钥对
  2. 首次身份认证:使用公钥登录IAM,获得与公钥绑定的临时授权码
  3. 安全传输保障:所有关键数据都经过数字签名,防篡改、可验证
  4. 双重签名验证:OpenResty网关和应用服务器共同验证请求的合法性
  5. 令牌精准绑定:最终颁发的JWT令牌与特定客户端密钥对严格绑定

这个设计确保了:即使Token被截获,没有对应的私钥也无法使用;即使请求被拦截,没有正确的签名也无法伪造。

第二章:攻坚——OpenResty上的“依赖地狱”与AI的局限

理论很美好,但实践起来却是另一番景象。当我们在OpenResty网关中实现ES256签名验证时,遇到了意想不到的困境。

问题在于:OpenResty的默认安装包并不包含现成的椭圆曲线加密库。我们开始了一场与AI工具的深度协作:

  • 向GPT询问方案:得到的是基于lua-resty-opensslluaossl的通用建议
  • 用Cursor尝试实现:生成的代码看似合理,但总在运行时报错
  • Deepseek提供思路:给出了几种不同的依赖组合方案

然而,我们很快陷入了一个“依赖怪圈”:

为了解决A,需要安装B;安装B时,发现需要C的特定版本;编译C时,又需要A的某个功能……如此循环,无休无止。

更关键的是,我们最终锚定的那个较新的、专门为OpenResty优化的Lua加密库luoss-rel-20250929,其文档和API尚未被AI训练数据收录。这意味着:

  • AI无法理解这个库的特定设计模式
  • AI生成的代码基于过时或通用的库,无法直接使用
  • 我们遇到了AI的“知识边界”

这是一个重要的发现:AI的能力受限于其训练数据的时效性和覆盖面。对于前沿的、小众的、刚发布的技术文档,AI可能一无所知。

第三章:协同——人与AI的正确分工

认识到AI的局限后,我们调整了策略,形成了全新的“人机协同”工作流:

第一步:人类负责“战略阅读”与“深度理解”

我花了整整一个下午,仔细阅读那个新库的英文文档。虽然过程缓慢,但我逐渐理解了:

  • 库的设计哲学和核心抽象
  • 密钥生成、签名、验证的API调用方式
  • 必要的依赖关系和编译选项
  • 与OpenResty生态集成的要点

第二步:将“理解后的知识”喂给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 OpenSSL
identifier 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 curve
The 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 -out
dh-2048.pem -outform PEM 2048”. Using the field “dhparam” overrides the “bits” field.
具体需求:实现一个Lua模块,实现密钥的生成,存储,签名和验证的方法,供sign_api.lua调用

第三步:AI的精准助攻

这一次,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 + 1
end

-- 从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, y
end

-- 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 DER
local 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 JWT
function _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 .. "." .. signature
end

-- generate with config active key
function _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 content
end

-- 加载密钥配置
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 nil
end

-- 重新加载密钥(用于密钥轮换)
function _M.reload_keys()
    cached_keys = nil
    return _M.get_active_key() ~= nil
end

-- 获取 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_key
end

-- 获取 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_key
end

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

这个代码不仅语法正确,而且:

  • 完全符合新库的API规范
  • 错误处理完整
  • 与项目现有的Lua模块风格一致
  • 考虑了OpenResty的特殊环境

总结与思考:我们的AI开发哲学

经过这次实践,我们形成了清晰的“人机协作”开发哲学:

AI的三大核心价值

  1. 创意激发与方案探索:在初期探索阶段,AI能快速提供多种思路(如发现DPoP协议)
  2. 代码生成与模板创建:对于通用模式、重复性代码,AI能极大提升效率
  3. 问题诊断与调试辅助:遇到错误时,AI能提供可能的解决方案和调试方向

人类不可替代的三大角色

  1. 架构师与决策者:在技术选型、方案设计等关键决策上,人类的专业判断无可替代
  2. 前沿知识的学习者:对于AI尚未掌握的新技术、新文档,人类必须亲自阅读和理解
  3. 质量守门员:对AI生成的所有代码,必须进行阅读,审查,测试。

给技术团队的实用建议

  1. 明确分工:让AI做它擅长的事(生成、搜索、建议),让人做AI不擅长的事(理解、决策、判断)
  2. 验证一切:对AI输出的任何方案,都要用批判性思维验证,尤其是在安全领域
  3. 持续学习:AI工具在进化,我们的使用方式也需要不断优化。建立适合自己的AI协作流程

展望:更开放、更安全的SaaS未来

如今,谷雨SaaS平台的DPoP安全架构已经贯通。这套方案不仅解决了前端应用化的安全难题,更为平台的开放生态奠定了基础:

  • 第三方开发者可以基于这套安全协议,开发合规的前端应用
  • 企业客户可以放心地将敏感业务部署在平台上
  • 持续交付真正成为可能,每个应用可以独立更新、部署

更令人兴奋的是,我们找到了一条高效的人机协作路径。在这个过程中,AI不是替代者,而是真正的“搭档”——它放大了我们的能力边界,让我们能专注于更高层次的设计和决策。

开源SaaS平台的未来,是开放的、安全的、智能的。在谷雨平台的下一阶段,我们将继续探索AI在测试生成、文档编写、性能优化等更多场景的应用。同时,我们也期待与更多开发者一起,共同探索AI时代的新型开发模式。


思考题:在你的开发实践中,是否遇到过类似的“AI边界”时刻?你是如何与AI工具协同解决复杂技术问题的?欢迎在评论区分享你的经验。

谷雨开源SaaS平台正在招募早期贡献者,如果你对微服务架构、SaaS中台、或SaaS垂直领域能力(如:CMD, CRM等)感兴趣,欢迎访问我们的GitHub仓库https://github.com/g2rain/g2rain或通过公众号“谷雨开源SaaS”加入讨论。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐