forked from alterminal/alterminal
重組前檢查點:根目錄 main package
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
// Package jwk 提供簽發 JWT(ID Token/Access Token)所用金鑰的資料模型,
|
||||
// 以及其 RFC 7517 JWK/JWKS 公開表示法,供 /.well-known/jwks.json 發佈。
|
||||
package jwk
|
||||
|
||||
// JWK 參數的註冊值(RFC 7517 的 kty/use、RFC 7518 的 alg)。
|
||||
const (
|
||||
KeyTypeRSA = "RSA" // kty:金鑰類型
|
||||
KeyUseSig = "sig" // use:簽章用途
|
||||
AlgRS256 = "RS256" // alg:RSASSA-PKCS1-v1_5 搭配 SHA-256
|
||||
)
|
||||
|
||||
// JWK 為單一把金鑰的公開形式(RFC 7517),僅含 RP 驗證 JWT 簽章所需的
|
||||
// 參數;私有參數(d、p、q、dp、dq、qi)依規範與安全考量絕不序列化。
|
||||
type JWK struct {
|
||||
Kty string `json:"kty"` // 金鑰類型(RSA)
|
||||
Use string `json:"use"` // 用途(sig)
|
||||
Kid string `json:"kid"` // 金鑰 ID,對應 JWT header 的 kid
|
||||
Alg string `json:"alg,omitempty"` // 建議演算法(RS256)
|
||||
N string `json:"n"` // RSA modulus,base64url 無填充
|
||||
E string `json:"e"` // RSA 公開指數,同上(65537 時為 "AQAB")
|
||||
}
|
||||
|
||||
// JWKS 為 JWK Set(RFC 7517 §5),/.well-known/jwks.json 的回應格式。
|
||||
type JWKS struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package jwk
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rsaKeyBits 為簽章金鑰位元數;RFC 7518 §3.5 規定 RS256 至少 2048 bits。
|
||||
const rsaKeyBits = 2048
|
||||
|
||||
// SigningKey 為簽發 JWT 的 RSA 金鑰,對應 signing_keys 資料表。私鑰以
|
||||
// PKCS#8 PEM 存於資料庫;Kid 為 RFC 7638 thumbprint,同時作為 JWKS 的
|
||||
// kid 與 JWT header 的 kid,讓 RP 得以對應兩者。RetiredAt 為 nil 表示
|
||||
// 使用中;輪替時舊金鑰先保留於 JWKS 一段時間(供已簽發的 token 驗證),
|
||||
// 之後才退休停發,實現無縫金鑰輪替。
|
||||
type SigningKey struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Kid string `gorm:"uniqueIndex;size:43;not null"` // RFC 7638 thumbprint(32 bytes 的 base64url,43 字元)
|
||||
Algorithm string `gorm:"size:8;not null;default:RS256"`
|
||||
PrivateKeyPEM string `gorm:"type:text;not null"` // PKCS#8 PEM 私鑰
|
||||
RetiredAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Active 回傳金鑰是否使用中(未退休)。
|
||||
func (k *SigningKey) Active() bool {
|
||||
return k.RetiredAt == nil
|
||||
}
|
||||
|
||||
// NewSigningKey 產生新的 RSA-2048 簽章金鑰,並以公鑰的 RFC 7638
|
||||
// SHA-256 thumbprint 作為 Kid。
|
||||
func NewSigningKey() (*SigningKey, error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, rsaKeyBits)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate rsa key: %w", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal private key: %w", err)
|
||||
}
|
||||
kid, err := thumbprint(&key.PublicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SigningKey{
|
||||
Kid: kid,
|
||||
Algorithm: AlgRS256,
|
||||
PrivateKeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PrivateKey 解析並回傳 RSA 私鑰,供簽發 JWT 使用。
|
||||
func (k *SigningKey) PrivateKey() (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(k.PrivateKeyPEM))
|
||||
if block == nil {
|
||||
return nil, errors.New("invalid PEM block")
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
key, ok := parsed.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not an RSA private key: %T", parsed)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// PublicJWK 回傳金鑰的公開 JWK(僅 kty、use、kid、alg、n、e),
|
||||
// 供 JWKS 端點發佈。
|
||||
func (k *SigningKey) PublicJWK() (*JWK, error) {
|
||||
key, err := k.PrivateKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &JWK{
|
||||
Kty: KeyTypeRSA,
|
||||
Use: KeyUseSig,
|
||||
Kid: k.Kid,
|
||||
Alg: k.Algorithm,
|
||||
N: base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
|
||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// thumbprint 依 RFC 7638 計算 RSA 公鑰的 SHA-256 JWK thumbprint:對必要
|
||||
// 參數依字典序(e、kty、n)組成的正規化 JSON 做 SHA-256,再以無填充
|
||||
// base64url 編碼;此值即 JWK/JWT 的 kid。
|
||||
func thumbprint(pub *rsa.PublicKey) (string, error) {
|
||||
// 欄位依字典序宣告,json.Marshal 的輸出即 RFC 7638 要求的正規化形式;
|
||||
// 成員僅含 base64url 字元,不會有 JSON 跳脫的差異。
|
||||
canonical := struct {
|
||||
E string `json:"e"`
|
||||
Kty string `json:"kty"`
|
||||
N string `json:"n"`
|
||||
}{
|
||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
|
||||
Kty: KeyTypeRSA,
|
||||
N: base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||
}
|
||||
b, err := json.Marshal(canonical)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal canonical jwk: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:]), nil
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package jwk
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewSigningKey(t *testing.T) {
|
||||
k, err := NewSigningKey()
|
||||
if err != nil {
|
||||
t.Fatal("NewSigningKey: ", err)
|
||||
}
|
||||
if len(k.Kid) != 43 {
|
||||
t.Errorf("Kid 應為 32 bytes SHA-256 的無填充 base64url(43 字元),得到 %d 字元", len(k.Kid))
|
||||
}
|
||||
if k.Algorithm != AlgRS256 {
|
||||
t.Errorf("Algorithm = %q, want %q", k.Algorithm, AlgRS256)
|
||||
}
|
||||
if !k.Active() {
|
||||
t.Error("新產生的金鑰應為使用中(RetiredAt 為 nil)")
|
||||
}
|
||||
priv, err := k.PrivateKey()
|
||||
if err != nil {
|
||||
t.Fatal("PrivateKey: ", err)
|
||||
}
|
||||
if priv.N.BitLen() != rsaKeyBits {
|
||||
t.Errorf("金鑰長度 = %d bits, want %d", priv.N.BitLen(), rsaKeyBits)
|
||||
}
|
||||
if err := priv.Validate(); err != nil {
|
||||
t.Error("產生的私鑰未通過自檢: ", err)
|
||||
}
|
||||
want, err := thumbprint(&priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal("thumbprint: ", err)
|
||||
}
|
||||
if k.Kid != want {
|
||||
t.Errorf("Kid = %q, want 公鑰 thumbprint %q", k.Kid, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSigningKeyUniqueKid(t *testing.T) {
|
||||
a, err := NewSigningKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := NewSigningKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Kid == b.Kid {
|
||||
t.Error("兩把新產生的金鑰不應有相同 Kid")
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 7638 §3.1 的測試向量(與 RFC 7517 A.1 同一把 RSA 金鑰)。
|
||||
func TestThumbprintRFC7638Vector(t *testing.T) {
|
||||
pub := &rsa.PublicKey{
|
||||
N: mustBigFromBase64URL(t, "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw"),
|
||||
E: 65537,
|
||||
}
|
||||
got, err := thumbprint(pub)
|
||||
if err != nil {
|
||||
t.Fatal("thumbprint: ", err)
|
||||
}
|
||||
if want := "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"; got != want {
|
||||
t.Errorf("thumbprint = %q, want RFC 7638 §3.1 的 %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActive(t *testing.T) {
|
||||
k := &SigningKey{}
|
||||
if !k.Active() {
|
||||
t.Error("RetiredAt 為 nil 時應為使用中")
|
||||
}
|
||||
now := time.Now()
|
||||
k.RetiredAt = &now
|
||||
if k.Active() {
|
||||
t.Error("RetiredAt 已設定時不應為使用中")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicJWK(t *testing.T) {
|
||||
k, err := NewSigningKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j, err := k.PublicJWK()
|
||||
if err != nil {
|
||||
t.Fatal("PublicJWK: ", err)
|
||||
}
|
||||
priv, err := k.PrivateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if j.Kty != "RSA" || j.Use != "sig" || j.Alg != "RS256" {
|
||||
t.Errorf("JWK 參數 = kty:%q use:%q alg:%q", j.Kty, j.Use, j.Alg)
|
||||
}
|
||||
if j.Kid != k.Kid {
|
||||
t.Errorf("JWK.Kid = %q, want %q", j.Kid, k.Kid)
|
||||
}
|
||||
if j.E != "AQAB" {
|
||||
t.Errorf("E = %q, want AQAB(65537 的 base64url)", j.E)
|
||||
}
|
||||
if mustBigFromBase64URL(t, j.N).Cmp(priv.N) != 0 {
|
||||
t.Error("N 應等於私鑰的 modulus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicJWKJSONShape(t *testing.T) {
|
||||
k, err := NewSigningKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j, err := k.PublicJWK()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := json.Marshal(JWKS{Keys: []JWK{*j}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
if !strings.HasPrefix(s, `{"keys":[{`) || !strings.HasSuffix(s, `}]}`) {
|
||||
t.Errorf(`JWKS 應為 {"keys":[…]} 形式,得到 %s`, s)
|
||||
}
|
||||
var m struct {
|
||||
Keys []map[string]any `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := m.Keys[0]
|
||||
want := []string{"alg", "e", "kid", "kty", "n", "use"}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("JWK 應恰含參數 %v,得到 %v", want, got)
|
||||
}
|
||||
for _, f := range want {
|
||||
if _, ok := got[f]; !ok {
|
||||
t.Errorf("JWK 缺少參數 %q", f)
|
||||
}
|
||||
}
|
||||
// 私有參數絕不得出現
|
||||
for _, p := range []string{`"d":`, `"p":`, `"q":`, `"dp":`, `"dq":`, `"qi":`} {
|
||||
if strings.Contains(s, p) {
|
||||
t.Errorf("JWKS 不應含私有參數 %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateKeyMalformedPEM(t *testing.T) {
|
||||
for _, pemStr := range []string{
|
||||
"",
|
||||
"not a pem",
|
||||
"-----BEGIN PRIVATE KEY-----\nYm9ndXMK\n-----END PRIVATE KEY-----",
|
||||
} {
|
||||
k := &SigningKey{PrivateKeyPEM: pemStr}
|
||||
if _, err := k.PrivateKey(); err == nil {
|
||||
t.Errorf("格式無效的 PEM %q 不應解析成功", pemStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustBigFromBase64URL(t *testing.T, s string) *big.Int {
|
||||
t.Helper()
|
||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
t.Fatalf("decode base64url %q: %v", s, err)
|
||||
}
|
||||
return new(big.Int).SetBytes(b)
|
||||
}
|
||||
Reference in New Issue
Block a user