← All posts

Một cuộc chuyển đổi ES256, ba cách làm hỏng một chữ ký hợp lệ

Authagonal·July 20, 2026
jwtecdsaes256cryptovaultjwsdotnet

Bản thân việc chuyển đổi thuật toán diễn ra suôn sẻ. Bài viết Ba cuộc đời của khóa ký JWT của tôi kể câu chuyện đó: chuyển từ RS256 sang ES256 trên một issuer đang chạy, cả hai khóa được công bố trong JWKS suốt giai đoạn chuyển tiếp, một bộ chọn khóa hoạt động tự xoay khỏi khóa RSA cũ. Khóa, xoay vòng khóa, khám phá cấu hình: tất cả những phần nghe có vẻ khó đều chạy đúng ngay lần đầu.

Đây là câu chuyện về phần không suôn sẻ. Ba lần riêng biệt trong quá trình chuyển đổi đó, máy chủ xác thực của chúng tôi phát hành một token có chữ ký hợp lệ về mặt mật mã (phép toán đúng, đúng khóa đã ký đúng các byte) nhưng không một trình xác thực nào trên đời chấp nhận nó. Ba tầng khác nhau, mỗi tầng làm hỏng các byte theo cách riêng của mình, và từ bên ngoài, mỗi lần thất bại đều trông giống hệt nhau: invalid signature. Không phải "sai định dạng", không phải "độ dài không như mong đợi". Chỉ là: invalid.

Nếu bạn đang nhìn chằm chằm vào lỗi đó giữa chừng cuộc chuyển đổi, hãy nhảy thẳng đến phần cẩm nang thực chiến ở cuối bài. Độ dài của chữ ký sau khi giải mã gần như luôn chỉ ra tầng nào là thủ phạm.

RSA đã làm chúng ta chủ quan: một số nguyên, một cách mã hóa

Có một điều không ai nói với bạn trước khi chuyển sang ECDSA: RSA chưa bao giờ gặp vấn đề này, nên mọi trực giác của bạn đều không dùng được.

Một chữ ký RSA chính là một số nguyên duy nhất. Định dạng truyền tải chỉ là số nguyên đó, theo thứ tự big-endian, được đệm cho đủ kích thước khóa (256 byte với RSA-2048), và về cơ bản chỉ có một cách để viết nó ra. Ký bằng thư viện nào, xác thực bằng thư viện nào khác cũng được, các byte vẫn mang cùng một ý nghĩa.

Một chữ ký ECDSA là một cặp số nguyên, (r, s). Một cặp thì cần một cách mã hóa, và hệ sinh thái đã chốt lại hai cách không tương thích với nhau:

  • ASN.1 DER: một SEQUENCE gồm hai INTEGER. Độ dài thay đổi (thường 70–72 byte với P-256), luôn bắt đầu bằng byte 0x30. Đây là ngôn ngữ của OpenSSL, X.509 và TLS.
  • Raw R‖S (IEEE P1363): rs mỗi giá trị được đệm trái cho đủ đúng 32 byte rồi ghép lại. Luôn đúng 64 byte. Đây là định dạng JWS bắt buộc cho ES256 (RFC 7518 §3.4).

Cả hai đều mã hóa cùng một chữ ký. Cả hai đều "đúng". Đưa cho trình xác thực định dạng mà nó không mong đợi, nó sẽ không nói "sai vỏ bọc": nó giải mã ra những số nguyên rác và báo cáo điều duy nhất nó có thể báo: chữ ký không xác thực được.

Mỗi tầng nằm giữa khóa của bạn và đường truyền đều có quan điểm riêng về việc nó nói định dạng nào. Hệ thống của chúng tôi có ba tầng, và chúng tôi làm sai cả ba, lần lượt từng cái một.

Cái bẫy thứ nhất: KMS trả lời bằng DER

Khóa riêng của chúng tôi nằm trong transit engine của HashiCorp Vault và không bao giờ rời khỏi đó: ứng dụng gửi các byte cần ký và nhận về một chữ ký. Khi chúng tôi chuyển khóa transit từ rsa-2048 sang ecdsa-p256, yêu cầu ký vẫn còn mang theo một tàn dư từ thời RSA: signature_algorithm=pkcs1v15.

Đó là một tham số padding của RSA. Nó vô nghĩa với một khóa ECDSA. Vault không báo lỗi. Nó bỏ qua tham số không liên quan, ký bằng khóa đường cong, và trả về 200 kèm một chữ ký ECDSA hoàn toàn hợp lệ, ở định dạng marshaling mặc định của nó, tức là DER.

Thế là luồng chính vẫn xanh từ đầu đến cuối: yêu cầu được chấp nhận, chữ ký được trả về, openssl dgst -verify hẳn sẽ chứng nhận nó. Và mọi thư viện JWT từ chối mọi token, vì sau khi giải mã base64url phân đoạn thứ ba, nó mong đợi 64 byte R‖S nhưng lại thấy 71 byte bắt đầu bằng 0x30.

Cách sửa chỉ là một tham số trong yêu cầu: endpoint ký transit của Vault nhận marshaling_algorithm=jws, trả về raw R‖S trực tiếp. Nhưng hãy để ý hình dạng của cái bẫy: một tham số sai với loại khóabị bỏ qua trong im lặng. Yêu cầu trông có vẻ sai, thực sự sai, mà vẫn chạy được, theo một định nghĩa sai lầm của từ "chạy được".

Cái bẫy thứ hai: "cứ ghép hai số nguyên lại" làm hỏng một token trên mỗi 128

Nếu KMS của bạn không thể xuất R‖S một cách tự nhiên, bạn sẽ bị cám dỗ tự chuyển đổi DER: tách hai INTEGER ra, ghép lại, xong. Đây là cái bẫy khó chịu nhất trong cả ba, vì nó đúng một cách chập chờn.

Số nguyên trong DER có độ dài tối thiểu và có dấu. Điều đó nghĩa là:

  • một giá trị r tình cờ nhỏ về mặt số học (byte cao nhất bằng không) sẽ được mã hóa trong 31 byte hoặc ít hơn, vì DER lược bỏ các số không dẫn đầu;
  • một giá trị r có bit cao nhất được bật sẽ nhận thêm một byte dấu 0x00 và được mã hóa trong 33 byte.

Định dạng JWS không muốn cả hai điều đó. Nó muốn mỗi giá trị được đệm trái cho đủ đúng 32 byte, luôn luôn như vậy. Cách ghép ngây thơ các số nguyên DER tạo ra chữ ký 63, 64, 65 hoặc 66 byte tùy vận may, và chỉ những lần ra 64 byte mới xác thực được. Xử lý byte dấu nhưng quên phần đệm, và bạn còn lại một bộ chuyển đổi hỏng mỗi khi r hoặc s bắt đầu bằng một byte không: mỗi trường hợp là một sự kiện xác suất 1 trên 256, nên khoảng một token trên mỗi 128 sẽ bị thiếu byte.

Phiên bản đó vượt qua smoke test của bạn, vượt qua code review, được triển khai, rồi làm hỏng một lượt đăng nhập mỗi giờ trên môi trường production mà bạn không thấy quy luật nào. Chúng tôi né được nó bằng cách để Vault thực hiện việc chuyển đổi (lại là marshaling_algorithm=jws: hãy để bên sở hữu thao tác ký sở hữu luôn định dạng), nhưng cùng cái bẫy đó có một biến thể thuần cục bộ trong .NET: ECDsa.SignData/VerifyData nhận một DSASignatureFormat, và các góc khác nhau của framework có mặc định khác nhau: thế giới SignedXml/chứng chỉ tư duy theo Rfc3279DerSequence trong khi các API ECDsa thô mặc định dùng P1363. Đường xác thực cục bộ của chúng tôi giờ ghi rõ DSASignatureFormat.IeeeP1363FixedFieldConcatenation. Sau cuộc chuyển đổi này, chúng tôi không để bất kỳ API chữ ký nào tự chọn định dạng mặc định, ở bất cứ đâu.

Cái bẫy thứ ba: bản sửa lỗi đổi phương ngữ base64 ngay dưới chân chúng tôi

Vault bọc mọi chữ ký trong một lớp vỏ: vault:v1:<encoded-bytes>. Với marshaling DER mặc định, phần được mã hóa là base64 chuẩn, và client của chúng tôi ngoan ngoãn gọi Convert.FromBase64String trên nó.

Chuyển sang marshaling_algorithm=jws và Vault (một cách hợp lý, theo đúng spec mà nó đang nói) mã hóa phần chữ ký bằng base64url: bảng chữ cái -/_, không có phần đệm. Một cờ trong yêu cầu đã thay đổi hai thứ cùng lúc: định dạng byte chúng tôi yêu cầu, và cách mã hóa văn bản mà nó được gửi về.

Convert.FromBase64String muốn base64 chuẩn có phần đệm. Một chữ ký 64 byte là 86 ký tự base64url, không phải bội số của bốn, nên việc giải mã ném ra FormatException trên từng token một. Xét theo một khía cạnh nào đó, đây là thất bại dễ chịu nhất trong cả ba: ít nhất nó sập, một cách ồn ào, ngay tại đúng dòng code bị sai, thay vì tạo ra những byte trông có vẻ hợp lệ rồi âm thầm hỏng ở nơi khác. Cách sửa là giải mã (và, trên vòng xác thực ngược lại, mã hóa lại) bằng một codec base64url.

Thật trớ trêu: chữ ký cuối cùng cũng ở đúng định dạng byte, và giờ đến lượt tầng văn bản làm hỏng nó. Ba lớp mã hóa chồng lên nhau: mã hóa cặp số nguyên, phần đệm trường cố định, và mã hóa ký tự. Mỗi lớp đều có khả năng độc lập biến một chữ ký hợp lệ thành một token bị từ chối.

Cẩm nang thực chiến: độ dài chỉ ra thủ phạm

Khi một JWT không qua được bước xác thực giữa chừng cuộc chuyển đổi thuật toán, hãy giải mã base64url phân đoạn thứ ba của token và xem bạn có gì:

Chữ ký sau khi giải mã Tầng nào đã làm hỏng nó
Đúng 64 byte Định dạng truyền tải đã đúng, hãy tìm chỗ khác (kid không khớp, sai khóa trong JWKS, ghim cứng alg)
70–72 byte, byte đầu tiên là 0x30 DER đã lọt qua: KMS hoặc thư viện mật mã đang xuất ASN.1
63 hoặc 65 byte, và chỉ một số token hỏng Một bộ chuyển đổi DER→raw tự viết thiếu phần đệm trường cố định
Giải mã ném lỗi hoặc ra rác Lệch base64 với base64url ở tầng ngay bên dưới

Và một phép kiểm tra chéo giúp tách lỗi định dạng khỏi lỗi khóa trong vài giây: xác thực cùng chữ ký đó bằng OpenSSL, vốn nói DER. Nếu OpenSSL bảo hợp lệ trong khi mọi thư viện JWT bảo không hợp lệ, bạn có một vấn đề về mã hóa. Nếu cả hai đều bảo không hợp lệ, bạn có một vấn đề về khóa.

Bài học

"Hợp lệ về mặt mật mã" là một thuộc tính của toán học. "Xác thực được" là một thuộc tính của mọi ranh giới mã hóa nằm giữa toán học và bên tiêu thụ, và chữ ký của chúng tôi đã băng qua bốn ranh giới như vậy: marshaling của KMS, lớp vỏ truyền tải của nó, bước giải mã của client chúng tôi, và định dạng truyền tải JWS. Ba trong số các ranh giới đó có quan điểm riêng, và mỗi cái bất đồng với hàng xóm của nó đúng một lần.

Bài học về kiểm thử là điều chúng tôi thực sự thay đổi quy trình: đừng bao giờ chứng minh một đường ký bằng cách xác thực bằng chính code của mình. Đường ký và đường xác thực của chúng tôi chia sẻ mọi giả định kể trên, nên các vòng ký-rồi-xác-thực đều qua trót lọt trong khi mọi bên tiêu thụ thực tế đều từ chối chúng tôi. Bài kiểm thử cuối cùng đóng đinh được cả ba lỗi sẽ phát hành một token thật, xác thực nó bằng thư viện JWT có sẵn của nền tảng đối chiếu với JWKS đã công bố (giả định của người khác, không phải của chúng tôi), rồi khẳng định thêm một điều cụ thể đến tàn nhẫn: chữ ký sau khi giải mã phải đúng 64 byte. Phần toán học sẽ tự lo cho nó. Chính các byte mới là thứ bạn phải để mắt.