Skip to content

签名密钥 ​

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 私钥:

go
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
}
go
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,按四个阶段推进:

text
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)一一对应。零值时长由管理器补默认值:

字段默认值说明
Enabledfalse
AutoCreate选项中为 *bool,未设置视为 true为 false 时没有可用密钥就报错,不自动创建
RotationInterval90 天一把密钥的签名寿命
ActivationLeadTime等于 CacheTTL(5 分钟)candidate 预热时长,必须小于 RotationInterval
RetentionDuration14 天停止签名后继续发布公钥的时长;应覆盖最长令牌寿命 + 时钟偏差
CacheTTL5 分钟密钥快照缓存时长
ClockSkew5 分钟发布窗口的时钟偏差余量
AllowedAlgorithms["RS256"]可选 RS256/384/512、ES256/384/512
DefaultAlgorithm"RS256"新建密钥所用算法,必须在允许集合内
AllowMultipleActivefalse

选项结构里另有 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 实现,主密钥来自你的密钥管理系统,且所有副本使用同一把主密钥(否则其他副本解不开彼此生成的私钥):

go
// 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)
}

第二步,构造管理器并适配注入:

go
// 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):

go
records := pgstore.NewKeyRecordStore(pool, logger) // github.com/gonewx/wicket-pg/store
manager, err := useManagedKeys(cfg, records, masterKey, logger)

注入方法名是 SetRecordStore(不是 SetKeyRecordStore)。适配器的装配见 持久化存储。

相关阅读 ​

以 Apache License 2.0 发布 · 文档内容 © 2026 Wicket 作者