签名密钥
wicket 用非对称密钥签发 JWT(访问令牌、身份令牌),并在 /.well-known/openid-configuration/jwks 发布公钥供资源方验签。本页说明两种提供密钥的方式,以及 v0.1.4 下开启自动轮换的正确做法。
没有密钥就无法签发令牌
wicket 不会自动生成密钥。宿主既没有注入签名凭据、也没有按本页装配自动管理时,令牌端点返回 HTTP 501,错误信息为 no signing credential available for the requested algorithms,JWKS 为 {"keys":[]}。
两种方式怎么选
| 方式 | 适合 | 轮换 |
|---|---|---|
| 手动注入签名凭据 | 开发环境;或已有密钥管理系统(KMS、密钥文件 + 发布流程)的团队 | 由宿主自行安排,需手动处理新旧密钥并存 |
keymgmt 自动管理 | 希望 wicket 自动生成、预发布、轮换、保留旧公钥 | 自动;需持久化记录存储与加密保护器 |
手动提供签名凭据
两个注入点分工如下:
cfg.SetSigningCredentialStores(...):签名用的私钥凭据;cfg.SetValidationKeysStores(...):验签用的公钥,即 JWKS 输出的来源。
二者必须配套:签名用的 kid 必须出现在 JWKS 中,否则资源方无法验签。下例从 PEM 文件(PKCS#8)加载 RSA 私钥:
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"os"
wicketmodels "github.com/gonewx/wicket/models"
"github.com/gonewx/wicket/storage"
"github.com/gonewx/wicket/storage/memory"
"github.com/gonewx/wicket/tokens"
"github.com/lestrrat-go/jwx/v3/jwk"
)
func loadSigningKey(path string) ([]storage.SigningCredentialStore, []storage.ValidationKeysStore, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, nil, errors.New("no PEM block found")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, nil, err
}
priv, ok := parsed.(*rsa.PrivateKey)
if !ok {
return nil, nil, errors.New("not an RSA private key")
}
// kid 取 RFC 7638 JWK 指纹:同一公钥永远得到同一 kid
key, err := jwk.Import(priv)
if err != nil {
return nil, nil, err
}
if err := jwk.AssignKeyID(key); err != nil {
return nil, nil, err
}
kid, _ := key.KeyID()
cred, err := tokens.NewSigningCredentials(priv, tokens.WithAlgorithm("RS256"), tokens.WithKid(kid))
if err != nil {
return nil, nil, err
}
info := &wicketmodels.SecurityKeyInfo{Key: priv.Public(), SigningAlgorithm: cred.Algorithm}
return []storage.SigningCredentialStore{memory.NewMemorySigningCredentialStore(cred)},
[]storage.ValidationKeysStore{memory.NewMemoryValidationKeysStore([]*wicketmodels.SecurityKeyInfo{info})}, nil
}signing, validation, err := loadSigningKey("/run/secrets/wicket-signing.pem")
if err != nil {
log.Fatal(err)
}
cfg.SetSigningCredentialStores(signing)
cfg.SetValidationKeysStores(validation)要点:
- kid 用 RFC 7638 指纹,由公钥唯一决定。多副本加载同一把私钥时 kid 自然一致,无需另外约定。
- 开发时可以像 快速开始 那样在进程内
rsa.GenerateKey,但重启即换钥,之前签发的令牌全部无法验签,也不能多副本部署。 memory.NewMemorySigningCredentialStore/NewMemoryValidationKeysStore只是“把一份已加载的密钥包装成接口”,与“数据持久化”无关;密钥本身的持久与分发由你的密钥文件或 KMS 负责。- 手动轮换:先把新公钥加入验签存储(JWKS 同时发布新旧两把),等资源方的 JWKS 缓存刷新后再切换签名凭据;旧公钥至少保留到“最长令牌寿命 + 时钟偏差”之后再移除。两个注入点都有
...Provider(func() ...)形式,可以在运行时返回最新的密钥集合。
JWKS 发布
- 路径:
/.well-known/openid-configuration/jwks,发现文档的jwks_uri指向它。 - 内容:所有验签存储返回的公钥合并去重(同一 kid 只保留一个)。单个存储读取出错会被隔离,只有全部出错且没有任何可用密钥时才失败。
Discovery.ShowKeySet = false或Endpoints.EnableDiscoveryEndpoint = false时 JWKS 端点返回 404。
自动密钥管理
keymgmt 包提供一个密钥生命周期管理器 keymgmt.Manager。它把每把密钥的状态存成一条 keymgmt.Record,按四个阶段推进:
candidate ──预热期满──▶ active ──签名窗口结束──▶ retained ──保留期满──▶ retired
(已发布到 JWKS, (签名中, (不再签名,仍在 JWKS (不再出现在
尚不签名) 在 JWKS 中) 中,供已签发令牌验签) JWKS 中)- 首次没有可用密钥时,直接创建一把 active 密钥(不经过 candidate)。
- 当前 active 密钥的签名窗口剩余不足
ActivationLeadTime时,提前创建 candidate 并立即发布公钥;预热满max(ActivationLeadTime, CacheTTL)后转为 active,旧密钥进入 retained。 - 所有转换都在签名或读取 JWKS 时顺带完成(带缓存),管理器不启动后台 goroutine。
- 多副本共享同一个记录存储时,状态更新带版本号做乐观并发控制,各副本最终收敛,不会重复建钥。
以上行为已用注入时钟在 v0.1.4 上实测验证:轮换周期设为 24 小时时,到期前约 5 分钟生成 candidate,到期后新钥接替签名、旧钥转为 retained,保留期满后旧钥退出 JWKS。
配置项与默认值
keymgmt.Config 的字段与 WicketOptions.KeyManagement(KeyManagementOptions)一一对应。零值时长由管理器补默认值:
| 字段 | 默认值 | 说明 |
|---|---|---|
Enabled | false | |
AutoCreate | 选项中为 *bool,未设置视为 true | 为 false 时没有可用密钥就报错,不自动创建 |
RotationInterval | 90 天 | 一把密钥的签名寿命 |
ActivationLeadTime | 等于 CacheTTL(5 分钟) | candidate 预热时长,必须小于 RotationInterval |
RetentionDuration | 14 天 | 停止签名后继续发布公钥的时长;应覆盖最长令牌寿命 + 时钟偏差 |
CacheTTL | 5 分钟 | 密钥快照缓存时长 |
ClockSkew | 5 分钟 | 发布窗口的时钟偏差余量 |
AllowedAlgorithms | ["RS256"] | 可选 RS256/384/512、ES256/384/512 |
DefaultAlgorithm | "RS256" | 新建密钥所用算法,必须在允许集合内 |
AllowMultipleActive | false |
选项结构里另有 Protector(私钥保护器)、Clock、Entropy 三个注入点;记录存储则通过 cfg.SetRecordStore 注入。
v0.1.4:需要宿主自建管理器
修改 opts.KeyManagement 不会生效
容器内的 keymgmt.Manager 在 di.NewRoot() 时就已构造,并拷贝了当时的选项(Enabled = false)。之后再设置 opts.KeyManagement.Enabled = true 不会改变它(实测 root.Ptr_Manager().Enabled() 仍为 false),令牌端点照样报“无签名凭据”。
可行的做法是:宿主自己构造一个启用的管理器,再把它适配成签名凭据存储与验签存储注入。下面的代码已在 v0.1.4 上编译并端到端验证(client_credentials 签发成功,JWKS 发布该 kid)。
第一步,准备一个真正加密的保护器。管理器要求保护器能“改变并还原”数据;wicket 内置的默认实现虽能通过这项检查,但不适用于生产,不能为记录存储中的私钥提供保密。生产环境请使用类似下面的 AES-GCM 实现,主密钥来自你的密钥管理系统,且所有副本使用同一把主密钥(否则其他副本解不开彼此生成的私钥):
// aesGCMProtector 用 AES-256-GCM 加密私钥材料。
type aesGCMProtector struct{ aead cipher.AEAD }
func newAESGCMProtector(masterKey []byte) (*aesGCMProtector, error) {
block, err := aes.NewCipher(masterKey) // 32 字节 → AES-256
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &aesGCMProtector{aead: aead}, nil
}
// Create 同时满足 dp.DataProtectionProvider 与 dp.DataProtector。
func (p *aesGCMProtector) Create(string) dp.DataProtector { return p }
func (p *aesGCMProtector) Protect(plain []byte) []byte {
nonce := make([]byte, p.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
panic(fmt.Errorf("read nonce: %w", err))
}
return p.aead.Seal(nonce, nonce, plain, nil)
}
func (p *aesGCMProtector) Unprotect(sealed []byte) ([]byte, error) {
n := p.aead.NonceSize()
if len(sealed) < n {
return nil, errors.New("ciphertext too short")
}
return p.aead.Open(nil, sealed[:n], sealed[n:], nil)
}第二步,构造管理器并适配注入:
// managedSigning 把管理器适配为签名凭据存储。
type managedSigning struct{ m *keymgmt.Manager }
func (s managedSigning) GetSigningCredentials(ctx context.Context) (*tokens.SigningCredentials, error) {
candidates, err := s.m.SigningCandidates(ctx) // 已按优先级排序,首个即首选
if err != nil {
return nil, err
}
if len(candidates) == 0 {
return nil, keymgmt.ErrNoSigningKey
}
return candidates[0].Credential, nil
}
// managedValidation 把管理器适配为验签密钥存储(JWKS 来源)。
type managedValidation struct{ m *keymgmt.Manager }
func (v managedValidation) GetValidationKeys(ctx context.Context) ([]*wicketmodels.SecurityKeyInfo, error) {
return v.m.ValidationKeys(ctx)
}
func useManagedKeys(cfg *di.ServerConfig, records keymgmt.RecordStore, masterKey []byte, logger *slog.Logger) (*keymgmt.Manager, error) {
protector, err := newAESGCMProtector(masterKey)
if err != nil {
return nil, err
}
manager := keymgmt.NewManager(keymgmt.Config{
Enabled: true,
AutoCreate: true,
AllowedAlgorithms: []string{keymgmt.AlgRS256},
DefaultAlgorithm: keymgmt.AlgRS256,
// 时长留零值即使用默认:轮换 90 天、预热 5 分钟、保留 14 天
}, records, protector, nil, nil, logger)
cfg.SetSigningCredentialStores([]storage.SigningCredentialStore{managedSigning{manager}})
cfg.SetValidationKeysStores([]storage.ValidationKeysStore{managedValidation{manager}})
cfg.SetRecordStore(records) // 同一实例:满足 Freeze 对 key record store 的校验
return manager, nil
}说明:
keymgmt.NewManager的clock、entropy传nil时使用系统时钟与crypto/rand;records传nil时退回进程内存存储。- 保护器传
nil或无法往返还原时,管理器拒绝建钥和签名(fail-closed)。 - 管理器生成的 kid 同样是 RFC 7638 指纹。
- 这种装配下真正被使用的记录存储是传给
NewManager的那个实例;cfg.SetRecordStore只用于满足Freeze的注入校验,两处请传同一个实例。
多副本:注入持久化的记录存储
keymgmt.NewMemoryRecordStore() 只在单进程内有效:重启丢失全部密钥、各副本各自建钥,令牌在副本间无法互相验签。多副本或需要跨重启保持密钥时,必须使用共享的持久化 keymgmt.RecordStore,例如官方 PostgreSQL 适配器的 store.NewKeyRecordStore(pool, logger):
records := pgstore.NewKeyRecordStore(pool, logger) // github.com/gonewx/wicket-pg/store
manager, err := useManagedKeys(cfg, records, masterKey, logger)注入方法名是 SetRecordStore(不是 SetKeyRecordStore)。适配器的装配见 持久化存储。