Files
alterminal/internal/jwk/signingkey.go
T

118 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}