package core import ( "encoding/base64" "encoding/json" "os" "strings" "testing" ) // codearts JWK→PEM 重建判据。 // // 背景(真实缺陷,2026-10-19 用户报「华为云源用不了」): // // 华为云 STS 的 security_token 只活 1 小时,续期(refresh_token 换新)需要 // DPoP 请求签名。签名走 `codeartsDpopSign(key, ...)`,它要求 `key.pem` 是 // **PEM 字符串**(内部用 openssl 命令行签)。而持久化的凭据里**只有** // `dpop_private_key_jwk`(私钥的 JWK)—— PEM 是登录时的临时文件,没保存。 // // 于是续期必须先从 JWK 重建 PEM。这条判据钉住"重建出来的 PEM 真能被 openssl // 接受并签出有效的 ES256 JWS",而不是"函数返回了非空字符串"。 // // 为什么必须真签名:DER 结构错、长度字段错、base64 换行错,都会得到 // **非空但无效**的 PEM。只断言非空等于什么都没验。 func TestCodeartsRebuildsPemFromJwkAndSigns(t *testing.T) { // 一个结构合法的 P-256 私钥 JWK(生产凭据的真实形状:三个字段都是 // 32 字节 base64url)。用固定值而非随机生成,是为了让失败可复现。 jwk := map[string]any{ "crv": "P-256", "kty": "EC", "d": "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U", "x": "r2HUYfG7tMIWY9KOpXLvvMWE_7dnHM9K7p1K7-LSwl4", "y": "0-weI5p_EXZXFfGx4a76X_0oamkLOJ5AA4MOMObL8MM", } _, ps := poolCore(t) out, err := ps.Call("channels", "__codearts_jwk_pem_probe", map[string]any{"jwk": jwk}) if err != nil { t.Fatalf("探针调用失败: %v", err) } wrapped, _ := out.(map[string]any) inner, _ := wrapped["json"].(map[string]any) if inner == nil { t.Fatalf("探针返回缺 json 包装: %#v", out) } if e, _ := inner["error"].(string); e != "" { t.Fatalf("JWK→PEM 重建失败: %s", e) } // ① PEM 形状必须是 openssl 认的 SEC1 EC PRIVATE KEY pem, _ := inner["pem"].(string) if !strings.HasPrefix(pem, "-----BEGIN EC PRIVATE KEY-----") { t.Fatalf("PEM 头部不对: %q", firstLine(pem)) } if !strings.Contains(pem, "-----END EC PRIVATE KEY-----") { t.Fatalf("PEM 尾部不对: %q", lastLine(pem)) } // 每行(除首尾)不得超过 64 字符 —— openssl 对超长行会拒 for _, ln := range strings.Split(strings.TrimSpace(pem), "\n") { if strings.HasPrefix(ln, "-----") { continue } if len(ln) > 64 { t.Fatalf("PEM base64 行长度 %d > 64,openssl 会拒绝", len(ln)) } } // ② 最关键:真签名必须成功(DER/长度/换行任一错都在这里暴露) if ok, _ := inner["signed"].(bool); !ok { t.Fatalf("PEM 重建后 DPoP 签名失败: %v", inner["sign_error"]) } // ③ 签名产物必须是三段式 JWS(header.payload.signature) // 探针不直接回 JWS,但 signed=true 已表示 codeartsDpopSign 走完了 // 整个 openssl 签名 + DER→raw 转换链路(任一环节失败都会返回 nil)。 } func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s } func lastLine(s string) string { s = strings.TrimRight(s, "\n") if i := strings.LastIndexByte(s, '\n'); i >= 0 { return s[i+1:] } return s } // TestCodeartsKeyFromJwkRejectsBadJwk 钉住"坏 JWK 要响亮报错"。 // // 为什么需要:JWK 字段长度不对(例如被截断、或根本不是 P-256)时, // 若静默构造出一个畸形 PEM,openssl 只会说「签名失败」—— 运维无从判断 // 是凭据坏了还是代码坏了。必须在这里给可诊断的消息。 func TestCodeartsKeyFromJwkRejectsBadJwk(t *testing.T) { cases := []struct { name string jwk map[string]any }{ {"d 太短", map[string]any{ "kty": "EC", "crv": "P-256", "d": "AAAA", "x": "r2HUYfG7tMIWY9KOpXLvvMWE_7dnHM9K7p1K7-LSwl4", "y": "0-weI5p_EXZXFfGx4a76X_0oamkLOJ5AA4MOMObL8MM"}}, {"缺 x", map[string]any{ "kty": "EC", "crv": "P-256", "d": "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { _, ps := poolCore(t) out, err := ps.Call("channels", "__codearts_jwk_pem_probe", map[string]any{"jwk": c.jwk}) if err != nil { return // 调用层报错也算响亮 } wrapped, _ := out.(map[string]any) inner, _ := wrapped["json"].(map[string]any) if inner == nil { return } if e, _ := inner["error"].(string); e == "" { if ok, _ := inner["signed"].(bool); ok { t.Fatalf("坏 JWK 竟然签出了名 —— 应当报错") } } }) } } // 让 goimports/json 相关导入不被误判未使用(此文件仅用 json 做类型断言辅助)。 var _ = json.Marshal var _ = os.Getenv // TestB64urlDecodeHandlesLongInput 钉住 base64url 解码的**长输入**正确性。 // // 这个判据补的是一个真实的测漏:我第一版的 b64urlDecode 在输出字节后 // **没有清除已用掉的高位**(缺 `buf = buf % (2^nbits)`)。用 43 字符的 // JWK 字段(32 字节)测时**结果完全正确** —— 于是那条 PEM 判据在变异后 // 依然全绿,什么也没验到。 // // 只有长输入才暴露:累积缓冲不清高位时,double 精度在约 11 个 6bit 组后 // 耗尽,math.floor 给出越界值,string.char 直接抛 invalid value。 // 实测:把 `string.rep(43字符JWK, 10)` 喂进去,无掩码版本当场崩。 // // 所以这里必须用**长串**(远超过 11 组),断言解出的字节数与内容都正确。 func TestB64urlDecodeHandlesLongInput(t *testing.T) { // 96 个字符 → 72 字节。远超溢出临界点(11 组 = 66 bit)。 const in = "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7UBpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U" // 期望值由 Go 标准库算出(不用手写的 Lua 结果做锚,否则自证) want := base64.RawURLEncoding.EncodeToString _, ps := poolCore(t) out, err := ps.Call("channels", "__b64_probe", map[string]any{"s": in}) if err != nil { t.Fatalf("探针调用失败(很可能是 string.char 越界 = 缺高位掩码): %v", err) } wrapped, _ := out.(map[string]any) inner, _ := wrapped["json"].(map[string]any) if inner == nil { t.Fatalf("探针返回缺 json 包装: %#v", out) } got, _ := inner["hex"].(string) n, _ := inner["len"].(float64) // 期望长度由 Go 自己算(不要手推 len/4*3:输入不一定是 4 的倍数) dec, derr := base64.RawURLEncoding.DecodeString(in) if derr != nil { t.Fatal(derr) } if int(n) != len(dec) { t.Fatalf("解码长度 = %v,期望 %d", n, len(dec)) } // 与 Go 解码结果逐字节比对 wantHex := "" for _, b := range dec { wantHex += string("0123456789abcdef"[b>>4]) + string("0123456789abcdef"[b&0xf]) } if got != wantHex { t.Fatalf("解码内容不符(长输入下高位未清除):\n got=%s\nwant=%s", got, wantHex) } _ = want }