diff --git a/.env.example b/.env.example index 12a6767..2e70c59 100644 --- a/.env.example +++ b/.env.example @@ -45,7 +45,7 @@ FILE_UPLOAD_INSECURE_SKIP_VERIFY=true # 政务云文件上传限频:两次 POST 最短间隔(默认 10s;0 关闭;支持 10s 或纯数字秒) FILE_UPLOAD_MIN_INTERVAL=10s -# uploadToken Base64:padded(默认,含=)| raw(无填充,联调仍 403 可试) +# uploadToken Base64(FileAuth.java):padded(默认,含=)| raw(无填充,联调仍 403 可试) # FILEAUTH_B64=padded # 应用日志(默认 {程序目录}/log/transit/,含 pull/push/app 按日文件;每次执行有 BEGIN/END 分隔符) diff --git a/docs/FILEAUTH.md b/docs/FILEAUTH.md index 63f3c3a..faeea75 100644 --- a/docs/FILEAUTH.md +++ b/docs/FILEAUTH.md @@ -1,8 +1,8 @@ -# FileAuth 上传凭证(Ver2.0 §2.3.3) +# FileAuth 上传凭证(FileAuth.java) -生产仅使用 **§2.3.3**(HMAC 对 **encodedPutPolicy** 字符串)。FileAuth.java 旧算法(HMAC 对 policy JSON)**不可用**,不再维护。 +生产与 [`docs/FileAuth.java`](FileAuth.java) 中 `createUploadToken` **完全一致**(HMAC 对 **policy JSON 明文**)。 -## 生产 putPolicy(upload profile) +## 生产 putPolicy ```json {"scope":"","deadline":} @@ -10,29 +10,24 @@ - `scope`:**仅 bucket 名**(如 `other-doc`),**不要** `bucket:filename` - **无** `returnBody` -- HTTP 上传为 **PDF 二进制体**(`Content-Type: application/pdf`),**不使用** multipart;与 scope 无关 +- HTTP 上传为 **PDF 二进制体**(`Content-Type: application/pdf`),**不使用** multipart -## 五步 +## 生成步骤 | 步 | 说明 | |----|------| -| 1 | 构造 putPolicy(upload profile 见上) | -| 2 | `encodedPutPolicy = urlsafe_base64(putPolicy)` | -| 3 | `sign = HMAC-SHA1(secret, UTF-8(encodedPutPolicy))` | -| 4 | `encodedSign = urlsafe_base64(sign)` | -| 5 | `uploadToken = AccessKey + ':' + encodedSign + ':' + encodedPutPolicy` | +| 1 | 构造 putPolicy JSON(字段顺序:`scope` → `deadline`) | +| 2 | `sign = urlsafe_base64( HMAC-SHA1(secret, UTF-8(policyJSON)) )` | +| 3 | `policyB64 = urlsafe_base64( UTF-8(policyJSON) )` | +| 4 | `uploadToken = AccessKey + ':' + sign + ':' + policyB64` | ## Base64 填充 | 环境变量 | 说明 | |----------|------| -| `FILEAUTH_B64=padded`(默认) | URL-safe Base64 **含 `=`**(与文档教学样例一致) | +| `FILEAUTH_B64=padded`(默认) | URL-safe Base64 **含 `=`**(对应 Java `Base64.URL_SAFE \| NO_WRAP`) | | `FILEAUTH_B64=raw` | 无填充(联调仍 403 时可试) | -## 文档样例(doc profile,仅单测) - -教学样例含 `returnBody` 与 `scope=my-bucket:sunflower.jpg`,见 `auth_ver233_test.go` / `FileAuthServiceTest::test_create_upload_token_doc_golden`。 - ## 错误对照 | HTTP body | 含义 | @@ -42,10 +37,10 @@ ## Web 调试 -- `/fileauth`:生成 upload profile token;`?doc=1` 为文档黄金样例 +- `/fileauth`:生成 upload profile token - `/upload`:A=Runner 默认 token;B=本次上传 `tokenInfo`(`scope` 应为 bucket) -实现:[`internal/fileauth`](../internal/fileauth/ver233.go) +实现:[`internal/fileauth/upload.go`](../internal/fileauth/upload.go) ## 上传频率 diff --git a/internal/fileauth/auth.go b/internal/fileauth/auth.go index 6408934..eacbcec 100644 --- a/internal/fileauth/auth.go +++ b/internal/fileauth/auth.go @@ -1,4 +1,4 @@ -// Package fileauth 实现监管文件服务上传凭证(Ver2.0 §2.3.3,生产 scope=仅 bucket)。 +// Package fileauth 实现监管文件服务上传凭证(与 docs/FileAuth.java 一致,生产 scope=仅 bucket)。 package fileauth import ( @@ -7,7 +7,7 @@ import ( const defaultUploadExpires = 3600 * time.Second -// CreateUploadToken 生成上传凭证(§2.3.3 upload profile,scope=仅 bucket)。 +// CreateUploadToken 生成上传凭证(FileAuth.java upload profile,scope=仅 bucket)。 func CreateUploadToken(accessKey, secret, bucket string, expires time.Duration) (string, error) { if expires <= 0 { expires = defaultUploadExpires diff --git a/internal/fileauth/auth_test.go b/internal/fileauth/auth_test.go new file mode 100644 index 0000000..d522ddd --- /dev/null +++ b/internal/fileauth/auth_test.go @@ -0,0 +1,88 @@ +package fileauth + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "os" + "testing" +) + +func TestMain(m *testing.M) { + SetB64Mode(B64ModePadded) + os.Exit(m.Run()) +} + +func TestMarshalPutPolicyUpload_productionShape(t *testing.T) { + got, err := MarshalPutPolicyUpload("other-doc", 1735689600) + if err != nil { + t.Fatal(err) + } + want := `{"scope":"other-doc","deadline":1735689600}` + if string(got) != want { + t.Fatalf("upload policy mismatch\ngot: %s\nwant: %s", got, want) + } +} + +func TestBuildUploadTokenParts_javaAlgorithm(t *testing.T) { + const ( + accessKey = "accessKey" + secret = "secret" + bucket = "other-doc" + deadline = int64(1735689600) + policyJSON = `{"scope":"other-doc","deadline":1735689600}` + ) + + mac := hmac.New(sha1.New, []byte(secret)) + _, _ = mac.Write([]byte(policyJSON)) + wantSign := base64.URLEncoding.EncodeToString(mac.Sum(nil)) + wantPolicyB64 := base64.URLEncoding.EncodeToString([]byte(policyJSON)) + wantToken := accessKey + ":" + wantSign + ":" + wantPolicyB64 + + parts, err := BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadline) + if err != nil { + t.Fatal(err) + } + if parts.UploadToken != wantToken { + t.Fatalf("token mismatch\ngot: %s\nwant: %s", parts.UploadToken, wantToken) + } + if parts.SignInput != policyJSON { + t.Fatalf("signInput=%q want policy JSON", parts.SignInput) + } + if parts.EncodedSign != wantSign { + t.Fatalf("encodedSign=%q want %q", parts.EncodedSign, wantSign) + } + if parts.EncodedPutPolicy != wantPolicyB64 { + t.Fatalf("policyB64=%q want %q", parts.EncodedPutPolicy, wantPolicyB64) + } + if parts.Algorithm != algorithmJavaUpload { + t.Fatalf("algorithm=%q", parts.Algorithm) + } +} + +func TestCreateUploadTokenAtFile_ignoresFilenameInScope(t *testing.T) { + parts, err := BuildUploadTokenPartsAtFile("ak", "sec", "other-doc", "different.pdf", 1735689600) + if err != nil { + t.Fatal(err) + } + if parts.PolicyJSON != `{"scope":"other-doc","deadline":1735689600}` { + t.Fatalf("filename must not affect scope: %q", parts.PolicyJSON) + } +} + +func TestUploadToken_paddedVsRawB64(t *testing.T) { + SetB64Mode(B64ModePadded) + padded, err := BuildUploadTokenPartsForUpload("ak", "sec", "b", 1735689600) + if err != nil { + t.Fatal(err) + } + SetB64Mode(B64ModeRaw) + raw, err := BuildUploadTokenPartsForUpload("ak", "sec", "b", 1735689600) + if err != nil { + t.Fatal(err) + } + SetB64Mode(B64ModePadded) + if padded.UploadToken == raw.UploadToken { + t.Fatal("padded vs raw token should differ") + } +} diff --git a/internal/fileauth/auth_ver233_test.go b/internal/fileauth/auth_ver233_test.go deleted file mode 100644 index c8a030a..0000000 --- a/internal/fileauth/auth_ver233_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package fileauth - -import ( - "os" - "testing" -) - -func TestMain(m *testing.M) { - SetB64Mode(B64ModePadded) - os.Exit(m.Run()) -} - -func TestMarshalPutPolicyDoc_docExample(t *testing.T) { - got, err := MarshalPutPolicyDoc("my-bucket:sunflower.jpg", 1451491200) - if err != nil { - t.Fatal(err) - } - if string(got) != DocExamplePolicyJSON { - t.Fatalf("putPolicy mismatch\ngot: %s\nwant: %s", got, DocExamplePolicyJSON) - } -} - -func TestMarshalPutPolicyUpload_scopeBucketOnly(t *testing.T) { - got, err := MarshalPutPolicyUpload("other-doc", 1735689600) - if err != nil { - t.Fatal(err) - } - want := `{"scope":"other-doc","deadline":1735689600}` - if string(got) != want { - t.Fatalf("upload policy mismatch\ngot: %s\nwant: %s", got, want) - } -} - -func TestVer233_encodedPutPolicy_docExample(t *testing.T) { - got := urlSafeBase64Ver233([]byte(DocExamplePolicyJSON)) - if got != DocExampleEncodedPutPolicy { - t.Fatalf("encodedPutPolicy mismatch\ngot: %s\nwant: %s", got, DocExampleEncodedPutPolicy) - } -} - -func TestVer233_signHex_docExample(t *testing.T) { - hex := HMACSignHex("MY_SECRET_KEY", DocExampleEncodedPutPolicy) - if hex != DocExampleSignHex { - t.Fatalf("sign hex mismatch\ngot: %s\nwant: %s", hex, DocExampleSignHex) - } -} - -func TestVer233_encodedSign_docExample(t *testing.T) { - sign, err := signPolicyVer233("MY_SECRET_KEY", DocExampleEncodedPutPolicy) - if err != nil { - t.Fatal(err) - } - if sign != DocExampleEncodedSign { - t.Fatalf("encodedSign mismatch\ngot: %s\nwant: %s", sign, DocExampleEncodedSign) - } -} - -func TestVer233_fullToken_docExample(t *testing.T) { - parts, err := BuildUploadTokenFromPolicy("MY_ACCESS_KEY", "MY_SECRET_KEY", []byte(DocExamplePolicyJSON), "ver233-doc") - if err != nil { - t.Fatal(err) - } - if parts.UploadToken != DocExampleUploadToken { - t.Fatalf("token mismatch\ngot: %s\nwant: %s", parts.UploadToken, DocExampleUploadToken) - } - if parts.SignInput != DocExampleEncodedPutPolicy { - t.Fatal("signInput should be encodedPutPolicy") - } -} - -func TestBuildUploadTokenPartsForUpload_productionShape(t *testing.T) { - parts, err := BuildUploadTokenPartsForUpload("ak", "sec", "other-doc", 1735689600) - if err != nil { - t.Fatal(err) - } - if parts.Algorithm != "ver233-upload" { - t.Fatalf("algorithm=%q", parts.Algorithm) - } - if parts.SignInput != parts.EncodedPutPolicy { - t.Fatal("ver233 signInput must be encodedPutPolicy") - } - if parts.PolicyJSON != `{"scope":"other-doc","deadline":1735689600}` { - t.Fatalf("policy=%q", parts.PolicyJSON) - } -} - -func TestCreateUploadTokenAtFile_ignoresFilenameInScope(t *testing.T) { - parts, err := BuildUploadTokenPartsAtFile("ak", "sec", "other-doc", "different.pdf", 1735689600) - if err != nil { - t.Fatal(err) - } - if parts.PolicyJSON != `{"scope":"other-doc","deadline":1735689600}` { - t.Fatalf("filename must not affect scope: %q", parts.PolicyJSON) - } -} - -func TestVer233_uploadRawB64_differsFromPadded(t *testing.T) { - SetB64Mode(B64ModePadded) - padded, err := BuildUploadTokenPartsForUpload("ak", "sec", "b", 1735689600) - if err != nil { - t.Fatal(err) - } - SetB64Mode(B64ModeRaw) - raw, err := BuildUploadTokenPartsForUpload("ak", "sec", "b", 1735689600) - if err != nil { - t.Fatal(err) - } - SetB64Mode(B64ModePadded) - if padded.UploadToken == raw.UploadToken { - t.Fatal("padded vs raw token should differ") - } -} diff --git a/internal/fileauth/parts.go b/internal/fileauth/parts.go index e6080c7..8c99d19 100644 --- a/internal/fileauth/parts.go +++ b/internal/fileauth/parts.go @@ -7,12 +7,12 @@ import ( "strings" ) -// UploadTokenParts 上传凭证分步结果(监管 2.3:AccessKey:encodedSign:encodedPutPolicy)。 +// UploadTokenParts 上传凭证分步结果(FileAuth.java:AccessKey:encodedSign:policyB64)。 type UploadTokenParts struct { AccessKey string PolicyJSON string - EncodedPutPolicy string - SignInput string // §2.3.3:encodedPutPolicy 字符串 + EncodedPutPolicy string // 第三段:policy JSON 的 URL-safe Base64 + SignInput string // HMAC 输入:policy JSON 明文 EncodedSign string UploadToken string DeadlineUnix int64 @@ -27,41 +27,7 @@ type FormatCheck struct { Detail string `json:"detail"` } -// DocExample 监管 2.3 教学示例(含 returnBody,仅格式对照,非 transit 生产 policy)。 -type DocExample struct { - Note string `json:"note"` - UploadTokenSample string `json:"uploadTokenSample"` - AccessKeyPart string `json:"accessKeyPart"` - EncodedSignPart string `json:"encodedSignPart"` - EncodedPolicyPart string `json:"encodedPolicyPart"` - PolicyJSONDecoded string `json:"policyJsonDecoded"` -} - -// DocExampleRegulatory 内置监管文档第 5 步示例 token(含 returnBody 的完整 putPolicy)。 -const DocExampleRegulatory = "MY_ACCESS_KEY:wQ4ofysef1R7IKnrziqtomqyDvI=:eyJzY29wZSI6Im15LWJ1Y2tldDpzdW5mbG93ZXIuanBnIiwiZGVhZGxpbmUiOjE0NTE0OTEyMDAsInJldHVybkJvZHkiOiJ7XCJuYW1lXCI6JChmbmFtZSksXCJzaXplXCI6JChmc2l6ZSksXCJ3XCI6JChpbWFnZUluZm8ud2lkdGgpLFwiaFwiOiQoaW1hZ2VJbmZvLmhlaWdodCksXCJoYXNoXCI6JChldGFnKX0ifQ==" - -const docExamplePolicyJSON = `{"scope":"my-bucket:sunflower.jpg","deadline":1451491200,"returnBody":"{\"name\":$(fname),\"size\":$(fsize),\"w\":$(imageInfo.width),\"h\":$(imageInfo.height),\"hash\":$(etag)}"}` - -// RegulatoryDocExample 返回文档教学示例的拆分与解码(只读对照)。 -func RegulatoryDocExample() DocExample { - ak, sign, pol, ok := SplitUploadToken(DocExampleRegulatory) - policyDecoded := "" - if ok { - if s, err := DecodePutPolicyB64(pol); err == nil { - policyDecoded = s - } - } - return DocExample{ - Note: "监管 §2.3.3 教学示例(doc profile);生产为 upload profile(scope 仅 bucket,无 returnBody)", - UploadTokenSample: DocExampleRegulatory, - AccessKeyPart: ak, - EncodedSignPart: sign, - EncodedPolicyPart: pol, - PolicyJSONDecoded: policyDecoded, - } -} - -// BuildUploadTokenParts 分步生成上传凭证(§2.3.3 upload profile,scope=仅 bucket)。 +// BuildUploadTokenParts 分步生成上传凭证(生产 upload profile,scope=仅 bucket)。 func BuildUploadTokenParts(accessKey, secret, bucket string, deadlineUnix int64) (UploadTokenParts, error) { return BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadlineUnix) } @@ -94,7 +60,7 @@ func ValidateUploadTokenFormat(token, expectedAccessKey string) (allOK bool, che { ID: "three_parts", OK: ok, - Detail: "uploadToken = AccessKey + ':' + encodedSign + ':' + encodedPutPolicy(按前两处冒号拆分)", + Detail: "uploadToken = AccessKey + ':' + encodedSign + ':' + policyB64(按前两处冒号拆分)", }, { ID: "access_key_non_empty", @@ -109,7 +75,7 @@ func ValidateUploadTokenFormat(token, expectedAccessKey string) (allOK bool, che { ID: "encoded_policy_non_empty", OK: ok && pol != "", - Detail: "第 3 段 encodedPutPolicy(URL-safe Base64 policy JSON)非空", + Detail: "第 3 段 policyB64(URL-safe Base64 policy JSON)非空", }, } if expectedAccessKey != "" { @@ -137,7 +103,7 @@ func ValidateUploadTokenFormat(token, expectedAccessKey string) (allOK bool, che return allOK, checks } -// DecodePutPolicyB64 解码 encodedPutPolicy 为 JSON 字符串(美化缩进)。 +// DecodePutPolicyB64 解码 policyB64 为 JSON 字符串(美化缩进)。 func DecodePutPolicyB64(encoded string) (string, error) { encoded = strings.TrimSpace(encoded) if encoded == "" { @@ -145,7 +111,6 @@ func DecodePutPolicyB64(encoded string) (string, error) { } raw, err := base64.RawURLEncoding.DecodeString(encoded) if err != nil { - // 兼容带 padding 的标准 url-safe raw, err = base64.URLEncoding.DecodeString(encoded) if err != nil { return "", err @@ -161,8 +126,3 @@ func DecodePutPolicyB64(encoded string) (string, error) { } return string(out), nil } - -// DecodeDocExamplePolicy 返回内置文档示例 policy 明文(便于测试)。 -func DecodeDocExamplePolicy() string { - return docExamplePolicyJSON -} diff --git a/internal/fileauth/parts_test.go b/internal/fileauth/parts_test.go index d69c15c..4105d83 100644 --- a/internal/fileauth/parts_test.go +++ b/internal/fileauth/parts_test.go @@ -32,15 +32,15 @@ func TestBuildUploadTokenParts_matchesCreateUploadTokenAt(t *testing.T) { if strings.Contains(parts.PolicyJSON, "returnBody") { t.Fatalf("upload profile must not include returnBody: %q", parts.PolicyJSON) } - if parts.Algorithm != "ver233-upload" { + if parts.Algorithm != algorithmJavaUpload { t.Fatalf("algorithm=%q", parts.Algorithm) } - if parts.SignInput != parts.EncodedPutPolicy { - t.Fatal("ver233 signInput must be encodedPutPolicy") + if parts.SignInput != parts.PolicyJSON { + t.Fatal("signInput must be policy JSON") } } -func TestValidateUploadTokenFormat_golden(t *testing.T) { +func TestValidateUploadTokenFormat_production(t *testing.T) { parts, err := BuildUploadTokenParts("accessKey", "secret", "other-doc", 1735689600) if err != nil { t.Fatal(err) @@ -50,36 +50,3 @@ func TestValidateUploadTokenFormat_golden(t *testing.T) { t.Fatalf("validate failed: %+v", checks) } } - -func TestSplitUploadToken_docExample(t *testing.T) { - ak, sign, pol, ok := SplitUploadToken(DocExampleRegulatory) - if !ok { - t.Fatal("split failed") - } - if ak != "MY_ACCESS_KEY" { - t.Fatalf("ak=%q", ak) - } - if sign != "wQ4ofysef1R7IKnrziqtomqyDvI=" { - t.Fatalf("sign=%q", sign) - } - if !strings.HasPrefix(pol, "eyJ") { - t.Fatalf("policy=%q", pol) - } - decoded, err := DecodePutPolicyB64(pol) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(decoded, "returnBody") { - t.Fatalf("decoded missing returnBody: %s", decoded) - } -} - -func TestRegulatoryDocExample(t *testing.T) { - ex := RegulatoryDocExample() - if ex.AccessKeyPart != "MY_ACCESS_KEY" { - t.Fatalf("ak=%q", ex.AccessKeyPart) - } - if ex.PolicyJSONDecoded == "" { - t.Fatal("empty decoded policy") - } -} diff --git a/internal/fileauth/profile.go b/internal/fileauth/profile.go deleted file mode 100644 index 492fc6a..0000000 --- a/internal/fileauth/profile.go +++ /dev/null @@ -1,9 +0,0 @@ -package fileauth - -// PolicyProfile putPolicy 形态:upload=生产(scope 仅 bucket);doc=监管教学样例单测。 -type PolicyProfile string - -const ( - ProfileUpload PolicyProfile = "upload" - ProfileDoc PolicyProfile = "doc" -) diff --git a/internal/fileauth/upload.go b/internal/fileauth/upload.go new file mode 100644 index 0000000..c3092cf --- /dev/null +++ b/internal/fileauth/upload.go @@ -0,0 +1,132 @@ +package fileauth + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +const algorithmJavaUpload = "fileauth-java-upload" + +// MarshalPutPolicyUpload 生产 putPolicy:scope=仅 bucket,无 returnBody(与 FileAuth.java HashMap 字段顺序一致)。 +func MarshalPutPolicyUpload(bucket string, deadlineUnix int64) ([]byte, error) { + bucket = strings.TrimSpace(bucket) + if bucket == "" { + return nil, errors.New("empty bucket") + } + if deadlineUnix <= 0 { + return nil, errors.New("invalid deadline") + } + scopeJSON, err := json.Marshal(bucket) + if err != nil { + return nil, err + } + return []byte(`{"scope":` + string(scopeJSON) + `,"deadline":` + strconv.FormatInt(deadlineUnix, 10) + `}`), nil +} + +// signPolicyJava 与 FileAuth.java sign(byte[]) 一致:HMAC-SHA1(secret, policyJSON) → URL-safe Base64。 +func signPolicyJava(secret string, policyJSON []byte) (string, error) { + mac := hmac.New(sha1.New, []byte(secret)) + if _, err := mac.Write(policyJSON); err != nil { + return "", fmt.Errorf("hmac: %w", err) + } + return encodeURLSafe(mac.Sum(nil)), nil +} + +// BuildUploadTokenFromPolicy 按 FileAuth.java createUploadToken 生成 token。 +func BuildUploadTokenFromPolicy(accessKey, secret string, putPolicyJSON []byte, algorithm string) (UploadTokenParts, error) { + accessKey = strings.TrimSpace(accessKey) + secret = strings.TrimSpace(secret) + if accessKey == "" || secret == "" { + return UploadTokenParts{}, errors.New("empty key or secret") + } + if len(putPolicyJSON) == 0 { + return UploadTokenParts{}, errors.New("empty putPolicy") + } + if algorithm == "" { + algorithm = algorithmJavaUpload + } + + policyJSON := string(putPolicyJSON) + sign, err := signPolicyJava(secret, putPolicyJSON) + if err != nil { + return UploadTokenParts{}, err + } + policyB64 := encodeURLSafe(putPolicyJSON) + token := accessKey + ":" + sign + ":" + policyB64 + + var policy struct { + Deadline int64 `json:"deadline"` + } + _ = json.Unmarshal(putPolicyJSON, &policy) + + return UploadTokenParts{ + AccessKey: accessKey, + PolicyJSON: policyJSON, + EncodedPutPolicy: policyB64, + SignInput: policyJSON, + EncodedSign: sign, + UploadToken: token, + DeadlineUnix: policy.Deadline, + Algorithm: algorithm, + }, nil +} + +// BuildUploadTokenPartsForUpload 生产路径:scope=仅 bucket。 +func BuildUploadTokenPartsForUpload(accessKey, secret, bucket string, deadlineUnix int64) (UploadTokenParts, error) { + accessKey = strings.TrimSpace(accessKey) + secret = strings.TrimSpace(secret) + bucket = strings.TrimSpace(bucket) + if accessKey == "" || secret == "" { + return UploadTokenParts{}, errors.New("empty key or secret") + } + if bucket == "" { + return UploadTokenParts{}, errors.New("empty bucket") + } + if deadlineUnix <= 0 { + return UploadTokenParts{}, errors.New("invalid deadline") + } + jsonPolicy, err := MarshalPutPolicyUpload(bucket, deadlineUnix) + if err != nil { + return UploadTokenParts{}, err + } + parts, err := BuildUploadTokenFromPolicy(accessKey, secret, jsonPolicy, algorithmJavaUpload) + if err != nil { + return UploadTokenParts{}, err + } + now := time.Now().Unix() + expires := deadlineUnix - now + if expires < 0 { + expires = 0 + } + parts.ExpiresSec = expires + return parts, nil +} + +// BuildUploadTokenPartsAtFile filename 不参与 scope(HTTP 为 PDF 二进制体,非 multipart)。 +func BuildUploadTokenPartsAtFile(accessKey, secret, bucket, filename string, deadlineUnix int64) (UploadTokenParts, error) { + _ = filename + return BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadlineUnix) +} + +// CreateUploadTokenAtFile 生产上传凭证。 +func CreateUploadTokenAtFile(accessKey, secret, bucket, filename string, deadlineUnix int64) (string, error) { + parts, err := BuildUploadTokenPartsAtFile(accessKey, secret, bucket, filename, deadlineUnix) + if err != nil { + return "", err + } + return parts.UploadToken, nil +} + +// HMACSignHex 返回 HMAC-SHA1(policyJSON) 原始字节的十六进制(调试用)。 +func HMACSignHex(secret, policyJSON string) string { + mac := hmac.New(sha1.New, []byte(secret)) + _, _ = mac.Write([]byte(policyJSON)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/fileauth/ver233.go b/internal/fileauth/ver233.go deleted file mode 100644 index ab70921..0000000 --- a/internal/fileauth/ver233.go +++ /dev/null @@ -1,182 +0,0 @@ -package fileauth - -import ( - "crypto/hmac" - "crypto/sha1" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "strconv" - "strings" - "time" -) - -// ReturnBodyTemplate 监管 Ver2.0 §2.3.3 文档示例中的 returnBody 固定模板。 -const ReturnBodyTemplate = `{"name":$(fname),"size":$(fsize),"w":$(imageInfo.width),"h":$(imageInfo.height),"hash":$(etag)}` - -// DocExamplePolicyJSON 文档教学样例 putPolicy(固定向量单测用)。 -const DocExamplePolicyJSON = `{"scope":"my-bucket:sunflower.jpg","deadline":1451491200,"returnBody":"{\"name\":$(fname),\"size\":$(fsize),\"w\":$(imageInfo.width),\"h\":$(imageInfo.height),\"hash\":$(etag)}"}` - -// DocExampleEncodedPutPolicy 文档 §2.3.3 步骤 2 给出的 encodedPutPolicy。 -const DocExampleEncodedPutPolicy = "eyJzY29wZSI6Im15LWJ1Y2tldDpzdW5mbG93ZXIuanBnIiwiZGVhZGxpbmUiOjE0NTE0OTEyMDAsInJldHVybkJvZHkiOiJ7XCJuYW1lXCI6JChmbmFtZSksXCJzaXplXCI6JChmc2l6ZSksXCJ3XCI6JChpbWFnZUluZm8ud2lkdGgpLFwiaFwiOiQoaW1hZ2VJbmZvLmhlaWdodCksXCJoYXNoXCI6JChldGFnKX0ifQ==" - -// DocExampleSignHex 文档样例 HMAC 原始字节十六进制(SecretKey=MY_SECRET_KEY)。 -const DocExampleSignHex = "c10e287f2b1e7f547b20a9ebce2aada26ab20ef2" - -// DocExampleEncodedSign 文档样例 encodedSign。 -const DocExampleEncodedSign = "wQ4ofysef1R7IKnrziqtomqyDvI=" - -// DocExampleUploadToken 文档样例完整 uploadToken(AccessKey=MY_ACCESS_KEY)。 -const DocExampleUploadToken = "MY_ACCESS_KEY:wQ4ofysef1R7IKnrziqtomqyDvI=:" + DocExampleEncodedPutPolicy - -// MarshalPutPolicyDoc 文档 profile:scope 可含文件名 + returnBody。 -func MarshalPutPolicyDoc(scope string, deadlineUnix int64) ([]byte, error) { - scope = strings.TrimSpace(scope) - if scope == "" { - return nil, errors.New("empty scope") - } - if deadlineUnix <= 0 { - return nil, errors.New("invalid deadline") - } - scopeJSON, err := json.Marshal(scope) - if err != nil { - return nil, err - } - rbJSON, err := json.Marshal(ReturnBodyTemplate) - if err != nil { - return nil, err - } - return []byte(`{"scope":` + string(scopeJSON) + `,"deadline":` + strconv.FormatInt(deadlineUnix, 10) + `,"returnBody":` + string(rbJSON) + `}`), nil -} - -// MarshalPutPolicyUpload 生产 profile:scope=仅 bucket,无 returnBody。 -func MarshalPutPolicyUpload(bucket string, deadlineUnix int64) ([]byte, error) { - bucket = strings.TrimSpace(bucket) - if bucket == "" { - return nil, errors.New("empty bucket") - } - if deadlineUnix <= 0 { - return nil, errors.New("invalid deadline") - } - scopeJSON, err := json.Marshal(bucket) - if err != nil { - return nil, err - } - return []byte(`{"scope":` + string(scopeJSON) + `,"deadline":` + strconv.FormatInt(deadlineUnix, 10) + `}`), nil -} - -// MarshalPutPolicyVer233 兼容旧名;等同 doc profile。 -func MarshalPutPolicyVer233(scope string, deadlineUnix int64) ([]byte, error) { - return MarshalPutPolicyDoc(scope, deadlineUnix) -} - -// signPolicyVer233 §2.3.3:HMAC-SHA1(secret, UTF-8(encodedPutPolicy)) → urlSafeB64。 -func signPolicyVer233(secret, encodedPutPolicy string) (string, error) { - mac := hmac.New(sha1.New, []byte(secret)) - if _, err := mac.Write([]byte(encodedPutPolicy)); err != nil { - return "", fmt.Errorf("hmac: %w", err) - } - return encodeURLSafe(mac.Sum(nil)), nil -} - -// urlSafeBase64Ver233 供单测;使用当前 B64 模式(doc 单测应 SetB64Mode(padded))。 -func urlSafeBase64Ver233(data []byte) string { - return encodeURLSafe(data) -} - -// BuildUploadTokenFromPolicy 按 §2.3.3 五步由 putPolicy JSON 生成 token。 -func BuildUploadTokenFromPolicy(accessKey, secret string, putPolicyJSON []byte, algorithm string) (UploadTokenParts, error) { - accessKey = strings.TrimSpace(accessKey) - secret = strings.TrimSpace(secret) - if accessKey == "" || secret == "" { - return UploadTokenParts{}, errors.New("empty key or secret") - } - if len(putPolicyJSON) == 0 { - return UploadTokenParts{}, errors.New("empty putPolicy") - } - if algorithm == "" { - algorithm = "ver233" - } - - encodedPutPolicy := encodeURLSafe(putPolicyJSON) - sign, err := signPolicyVer233(secret, encodedPutPolicy) - if err != nil { - return UploadTokenParts{}, err - } - token := accessKey + ":" + sign + ":" + encodedPutPolicy - - var policy struct { - Deadline int64 `json:"deadline"` - } - _ = json.Unmarshal(putPolicyJSON, &policy) - - return UploadTokenParts{ - AccessKey: accessKey, - PolicyJSON: string(putPolicyJSON), - EncodedPutPolicy: encodedPutPolicy, - SignInput: encodedPutPolicy, - EncodedSign: sign, - UploadToken: token, - DeadlineUnix: policy.Deadline, - Algorithm: algorithm, - }, nil -} - -// BuildUploadTokenPartsForUpload 生产路径:scope=仅 bucket,§2.3.3 验签。 -func BuildUploadTokenPartsForUpload(accessKey, secret, bucket string, deadlineUnix int64) (UploadTokenParts, error) { - accessKey = strings.TrimSpace(accessKey) - secret = strings.TrimSpace(secret) - bucket = strings.TrimSpace(bucket) - if accessKey == "" || secret == "" { - return UploadTokenParts{}, errors.New("empty key or secret") - } - if bucket == "" { - return UploadTokenParts{}, errors.New("empty bucket") - } - if deadlineUnix <= 0 { - return UploadTokenParts{}, errors.New("invalid deadline") - } - jsonPolicy, err := MarshalPutPolicyUpload(bucket, deadlineUnix) - if err != nil { - return UploadTokenParts{}, err - } - parts, err := BuildUploadTokenFromPolicy(accessKey, secret, jsonPolicy, "ver233-upload") - if err != nil { - return UploadTokenParts{}, err - } - now := time.Now().Unix() - expires := deadlineUnix - now - if expires < 0 { - expires = 0 - } - parts.ExpiresSec = expires - return parts, nil -} - -// BuildUploadTokenPartsAtFile filename 不参与 scope(HTTP 为 PDF 二进制体,非 multipart)。 -func BuildUploadTokenPartsAtFile(accessKey, secret, bucket, filename string, deadlineUnix int64) (UploadTokenParts, error) { - _ = filename - return BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadlineUnix) -} - -// CreateUploadTokenAtFile 生产上传凭证。 -func CreateUploadTokenAtFile(accessKey, secret, bucket, filename string, deadlineUnix int64) (string, error) { - parts, err := BuildUploadTokenPartsAtFile(accessKey, secret, bucket, filename, deadlineUnix) - if err != nil { - return "", err - } - return parts.UploadToken, nil -} - -// BuildUploadTokenPartsDoc 文档黄金样例(固定 scope/deadline)。 -func BuildUploadTokenPartsDoc(accessKey, secret string) (UploadTokenParts, error) { - return BuildUploadTokenFromPolicy(accessKey, secret, []byte(DocExamplePolicyJSON), "ver233-doc") -} - -// HMACSignHex 返回 HMAC-SHA1 原始字节的十六进制(调试用)。 -func HMACSignHex(secret, encodedPutPolicy string) string { - mac := hmac.New(sha1.New, []byte(secret)) - _, _ = mac.Write([]byte(encodedPutPolicy)) - return hex.EncodeToString(mac.Sum(nil)) -} diff --git a/internal/hyfile/push-2026-05-28.log b/internal/hyfile/push-2026-05-28.log index f63e469..abaadf2 100644 --- a/internal/hyfile/push-2026-05-28.log +++ b/internal/hyfile/push-2026-05-28.log @@ -30,3 +30,11 @@ [push] 2026/05/28 15:14:40 chromedp: html_len=87 data_url_len=152 [push] 2026/05/28 15:14:41 chromedp: pdf bytes=5205 [push] 2026/05/28 15:14:41 file upload throttle: wait 79ms (min interval 80ms) +[push] 2026/05/28 16:42:27 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/28 16:42:27 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/28 16:42:28 chromedp: pdf bytes=5205 +[push] 2026/05/28 16:42:28 file upload throttle: wait 80ms (min interval 80ms) +[push] 2026/05/28 16:42:39 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/28 16:42:39 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/28 16:42:39 chromedp: pdf bytes=5205 +[push] 2026/05/28 16:42:40 file upload throttle: wait 79ms (min interval 80ms) diff --git a/internal/hyfile/upload.go b/internal/hyfile/upload.go index 27868e5..8a3d312 100644 --- a/internal/hyfile/upload.go +++ b/internal/hyfile/upload.go @@ -50,7 +50,7 @@ func uploadHTTPClient() *http.Client { // UploadPDF 上传 PDF 至监管文件服务(通常经 forward-go),成功时返回 record.fileId。 // // uploadURL:FILE_UPLOAD_VIA_FORWARD=true 时为 forward 地址;否则为云端 config 返回的政务云 URL。 -// uploadToken:transit 按 §2.3.3 本地生成(scope=仅 bucket),放在 X-Authorization 请求头。 +// uploadToken:transit 按 FileAuth.java 本地生成(scope=仅 bucket),放在 X-Authorization 请求头。 // filename 仅用于本机落盘命名,不参与 HTTP 请求体。 // forwardToken:非空时设置 X-Forward-Token(与 forward-go FORWARD_SHARED_SECRET 对应)。 func UploadPDF(pdf []byte, filename, uploadURL, uploadToken, forwardToken string) (string, error) { @@ -122,7 +122,7 @@ func formatUploadHTTPError(status int, raw []byte, uploadURL string) error { case strings.EqualFold(body, "forbidden"): msg += ";可能为 forward-go 拒绝:检查 ALLOW_IPS、FORWARD_SHARED_SECRET 与 X-Forward-Token" case body == "404": - msg += ";政务云 uploadToken 验签失败(非 forward forbidden):核对 §2.3.3 scope 是否为仅 bucket 名、HMAC 输入为 encodedPutPolicy" + msg += ";政务云 uploadToken 验签失败(非 forward forbidden):核对 FileAuth.java scope 是否为仅 bucket 名、HMAC 输入为 policy JSON" msg += upload403Hints(body) default: if extra := jsonMessageField(raw); extra != "" { @@ -146,9 +146,9 @@ func upload403Hints(body string) string { hints = append(hints, "政务云文件上传约 10 秒 1 次,transit 已全局限频;若仍 403 请拉长 FILE_UPLOAD_MIN_INTERVAL 或稍后重试") } hints = append(hints, - "按 §2.3.3 排查:①NTP 时钟(deadline 为上传完成校验时间)", + "按 FileAuth.java 排查:①NTP 时钟(deadline 为上传完成校验时间)", "②HY_APP_KEY/HY_APP_SECRET/HY_FILE_BUCKET(policy scope 仅 bucket 名,不含文件名)", - "③token=AccessKey:encodedSign:encodedPutPolicy;sign=HMAC-SHA1(secret, UTF-8(encodedPutPolicy))", + "③token=AccessKey:encodedSign:policyB64;sign=HMAC-SHA1(secret, UTF-8(policyJSON))", "④仍失败可设 FILEAUTH_B64=raw 后重启 transit", "⑤/config 重新拉取", ) diff --git a/internal/hyfile/upload_test.go b/internal/hyfile/upload_test.go index c83cc8a..75ce449 100644 --- a/internal/hyfile/upload_test.go +++ b/internal/hyfile/upload_test.go @@ -83,8 +83,8 @@ func TestUploadPDF_forbiddenGovMessage(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "token expired") { t.Fatalf("err=%v", err) } - if !strings.Contains(err.Error(), "2.3") { - t.Fatalf("want 2.3 troubleshooting hints, got %v", err) + if !strings.Contains(err.Error(), "FileAuth.java") { + t.Fatalf("want FileAuth.java troubleshooting hints, got %v", err) } } diff --git a/internal/logweb/configview.go b/internal/logweb/configview.go index f9ca0d5..67be30c 100644 --- a/internal/logweb/configview.go +++ b/internal/logweb/configview.go @@ -218,7 +218,7 @@ func BuildConfigView(snap syncer.ConfigSnapshot, cfg config.Config) ConfigViewRe FileUploadURL: snap.FileUploadURL, - SourceNote: "uploadToken:Runner 按 §2.3.3 本地生成;appKey/secret/bucket 云端非空优先", + SourceNote: "uploadToken:Runner 按 FileAuth.java 本地生成;appKey/secret/bucket 云端非空优先", ConfigRefreshed: formatTime(snap.RefreshedAt), }, @@ -244,7 +244,7 @@ func BuildConfigView(snap syncer.ConfigSnapshot, cfg config.Config) ConfigViewRe if cloudToken != "" && localToken != "" && cloudToken != localToken { - view.Diagnostics = append(view.Diagnostics, "云端预生成 uploadToken 与 Runner 本地 token 不一致(已使用本地 §2.3.3,以 Runner 为准)") + view.Diagnostics = append(view.Diagnostics, "云端预生成 uploadToken 与 Runner 本地 token 不一致(已使用本地 FileAuth.java,以 Runner 为准)") } diff --git a/internal/logweb/fileauth_handlers.go b/internal/logweb/fileauth_handlers.go index 754ba3a..e757922 100644 --- a/internal/logweb/fileauth_handlers.go +++ b/internal/logweb/fileauth_handlers.go @@ -31,8 +31,6 @@ type FileAuthGenerateResponse struct { FormatChecks []fileauth.FormatCheck `json:"formatChecks"` RunnerTokenMatch bool `json:"runnerTokenMatch"` RunnerTokenPreview string `json:"runnerTokenPreview"` - DocGoldenMatch bool `json:"docGoldenMatch"` - DocExample fileauth.DocExample `json:"docExample"` } func (s *Server) handleAPIFileAuthGenerate(w http.ResponseWriter, r *http.Request) { @@ -62,26 +60,17 @@ func (s *Server) handleAPIFileAuthGenerate(w http.ResponseWriter, r *http.Reques bucket = strings.TrimSpace(snap.EffectiveFileBucket) } - isDoc := r.URL.Query().Get("doc") == "1" - if isDoc { - accessKey = "MY_ACCESS_KEY" - secret = "MY_SECRET_KEY" - } - if accessKey == "" || secret == "" { writeErr(w, 400, "生效 appKey/appSecret 未配置,请在 /config 重新拉取或配置 HY_APP_KEY/HY_APP_SECRET") return } - if !isDoc && bucket == "" { + if bucket == "" { writeErr(w, 400, "fileBucket(scope) 为空,请配置 HY_FILE_BUCKET") return } deadlineUnix := time.Now().Unix() + 3600 - if isDoc { - deadlineUnix = 1451491200 - } - if ds := strings.TrimSpace(r.URL.Query().Get("deadline")); ds != "" && !isDoc { + if ds := strings.TrimSpace(r.URL.Query().Get("deadline")); ds != "" { n, err := strconv.ParseInt(ds, 10, 64) if err != nil || n <= 0 { writeErr(w, 400, "invalid deadline (unix seconds)") @@ -90,34 +79,21 @@ func (s *Server) handleAPIFileAuthGenerate(w http.ResponseWriter, r *http.Reques deadlineUnix = n } - var parts fileauth.UploadTokenParts - var err error - if isDoc { - parts, err = fileauth.BuildUploadTokenPartsDoc(accessKey, secret) - } else { - parts, err = fileauth.BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadlineUnix) - } + parts, err := fileauth.BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadlineUnix) if err != nil { writeErr(w, 500, err.Error()) return } - scope := bucket - if isDoc { - scope = "my-bucket:sunflower.jpg" - } - formatOK, checks := fileauth.ValidateUploadTokenFormat(parts.UploadToken, accessKey) runnerTok := strings.TrimSpace(sr.UploadToken()) - runnerMatch := !isDoc && runnerTok != "" && runnerTok == parts.UploadToken - - docGolden := isDoc && parts.UploadToken == fileauth.DocExampleUploadToken + runnerMatch := runnerTok != "" && runnerTok == parts.UploadToken writeJSON(w, FileAuthGenerateResponse{ AccessKey: parts.AccessKey, SecretSet: secret != "", Bucket: bucket, - Scope: scope, + Scope: bucket, B64Mode: fileauth.B64Mode(), PolicyJSON: parts.PolicyJSON, EncodedPutPolicy: parts.EncodedPutPolicy, @@ -133,8 +109,6 @@ func (s *Server) handleAPIFileAuthGenerate(w http.ResponseWriter, r *http.Reques FormatChecks: checks, RunnerTokenMatch: runnerMatch, RunnerTokenPreview: maskPreview(runnerTok), - DocGoldenMatch: docGolden, - DocExample: fileauth.RegulatoryDocExample(), }) } diff --git a/internal/logweb/upload_handlers.go b/internal/logweb/upload_handlers.go index 4e048c7..2447b04 100644 --- a/internal/logweb/upload_handlers.go +++ b/internal/logweb/upload_handlers.go @@ -4,7 +4,7 @@ import ( syncer "xk-hy-transit-go/internal/sync" ) -// UploadTokenInfo 本次上传使用的 §2.3.3 凭证(Web 调试展示)。 +// UploadTokenInfo 本次上传使用的 FileAuth.java 凭证(Web 调试展示)。 type UploadTokenInfo struct { Algorithm string `json:"algorithm"` PolicyJSON string `json:"policyJson"` diff --git a/internal/logweb/web/fileauth.html b/internal/logweb/web/fileauth.html index 3f48c0d..7f6a995 100644 --- a/internal/logweb/web/fileauth.html +++ b/internal/logweb/web/fileauth.html @@ -1,403 +1,161 @@ - - - - - 上传凭证 §2.3.3 - + 上传凭证 FileAuth - - - -
-
- -

上传凭证(Ver2.0 §2.3.3)

- +

上传凭证(FileAuth.java)

-
- -

- - 五步:putPolicyencodedPutPolicy(URL-safe Base64)→ - - HMAC-SHA1(secret, encodedPutPolicy)encodedSign → - - uploadToken = AccessKey:encodedSign:encodedPutPolicy - + 与 docs/FileAuth.java 一致: + sign = HMAC-SHA1(secret, policyJSON) → + uploadToken = AccessKey:encodedSign:policyB64(policyB64 = urlsafe_base64(policyJSON))

- -
- - - - - - - + - - -
- -
- - -
- -

A. §2.3.3 生产算法(transit / xk-api)

- +
+

生产 upload profile(scope=仅 bucket)

- -

1. putPolicy(scope=仅 bucket,无 returnBody)

- +

1. putPolicy

-
-
- -

2. encodedPutPolicy = urlsafe_base64(putPolicy)

- -
- -
- -
- -

3. sign = HMAC-SHA1(secret, encodedPutPolicy UTF-8)

- -

signInput(签名字节串): · hex:

- +

2. sign = HMAC-SHA1(secret, policyJSON UTF-8) → encodedSign

+

signInput: · hex:

-
-
- -

4–5. uploadToken = AccessKey + ':' + encodedSign + ':' + encodedPutPolicy

- +

3. policyB64 = urlsafe_base64(putPolicy)

+
+
+
+

4. uploadToken = AccessKey + ':' + encodedSign + ':' + policyB64

-
- -

算法: · B64: · AccessKey: · scope: · deadline: - - ·

- +

算法: · B64: · AccessKey: · scope: · deadline:

- -
- -

B. 格式校验

- +

格式校验

-
    - -

    与 Runner 当前 token(默认 prescription.pdf):

    - +

    与 Runner 当前 token:

    - - - -
    - -

    C. 监管文档教学示例(doc profile,只读)

    - -

    - -
    - -

    示例 token 三段

    - -
    - -
    - -
    - -

    encodedPutPolicy 解码

    - -
    - -
    - -
    -
    - - - - - - diff --git a/internal/logweb/web/index.html b/internal/logweb/web/index.html index 6112555..1539072 100644 --- a/internal/logweb/web/index.html +++ b/internal/logweb/web/index.html @@ -13,11 +13,11 @@

    互医配置

    -

    查看 xk-api 云端与本地 .env、Runner 生效值;本地 §2.3.3 生成 uploadToken。

    +

    查看 xk-api 云端与本地 .env、Runner 生效值;本地 FileAuth.java 生成 uploadToken。

    上传凭证

    -

    §2.3.3 upload profile(scope=仅 bucket),文档黄金样例校验。

    +

    FileAuth.java upload profile(scope=仅 bucket),分步生成与格式校验。

    文件上传

    diff --git a/internal/logweb/web/upload.html b/internal/logweb/web/upload.html index b5d5cfb..7dc4014 100644 --- a/internal/logweb/web/upload.html +++ b/internal/logweb/web/upload.html @@ -18,7 +18,7 @@ -

    §2.3.3 生产 token:scope=仅 bucket 名。上传为 PDF 二进制体(Content-Type: application/pdf),不用 multipart。403 且 body 为 404 表示 token 验签失败。

    +

    FileAuth.java 生产 token:scope=仅 bucket 名。上传为 PDF 二进制体(Content-Type: application/pdf),不用 multipart。403 且 body 为 404 表示 token 验签失败。

    @@ -33,15 +33,15 @@
    -

    B. 本次上传凭证(§2.3.3)

    +

    B. 本次上传凭证(FileAuth.java)

    上传成功或失败后显示本次请求使用的 token 信息。