介绍
在这篇博客文章中,我们将展示如何使用Tink Cryptography库来创建,签名和验证JSON Web令牌(JWTS),以及管理加密密钥进行此操作。这是一个更实用的例子,以补充Tink自己关于基础加密理论及其方法的文档。
在SlashID,我们是Tink的忠实拥护者,并以各种角色使用它,包括签名令牌!在我们的blog
上阅读有关它的更多信息。背景
由于三个核心特征,我们选择了丁克:
- 易于使用的API的淡淡理念很难搞砸,并且使用安全的默认值 - 我们将在下面看到错误的库如何使您的令牌服务完全不安全
- 钥匙旋转和管理盒
- Tink加密API和与KMS的集成使得易于保护签名密钥
在我们深入研究教程之前,让我们简要介绍JWTS。
JSON网络令牌
JWT是一种行业标准,广泛用于身份,身份验证和用户管理领域。它们用于以可验证的方式在两方之间传输信息。 JWT有许多出色的介绍,因此为了讨论的目的,我们将重点关注结构。
JWT通常以基本-64编码字符串的形式传输,并由由周期分开的三个部分组成:
- 一个包含关于令牌本身的元数据的标题
- 有效载荷,一套由JSON形式的索赔
- 可用于验证有效载荷内容的签名
例如,这个JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvZSBTbGFzaElEIiwiaWF0IjoxNTE2MjM5MDIyfQ.4cL42NsNCXLPEvmvNGxHN3wLuarpp98wwezHnSt2fqg
有以下部分
部分 | 编码值 | 解码值 | 描述 |
---|---|---|---|
标题 | eyjhbgcioijiuzi1niisinr5cci6ikpxvcj9 | {“ alg”:“ hs256”,“ typ”:“ jwt”} | 表示这是JWT,并且使用HS256算法(使用SHA-256的HMAC)hash hash |
有效载荷 | eyjzdwiioiixmjmjm0nty3odkwiiwibmftzsi6ikpvzsbtbgfzaeleiiiwiawiawiawijoxnte2mjmjmjm5mdiyfq | {“ sub”:“ 1234567890”,“名称”:“ slashid用户”,“ IAT”:1516239022} | 有效载荷,有关用户和令牌 | 的有效载荷>
签名 | 4cl42nsnsncxlpevmvngxhn3wluarpp98wwezhnst2fqg | n/a | 使用验证有效载荷 | 的HS256算法生成的签名
重要的方面是JWT是签名的,这意味着有效载荷中的主张可以验证,如果可以访问适当的加密密钥。
JWT令牌的签名计算如下:
signAndhash(base64UrlEncode(header) + '.' + base64UrlEncode(payload))
signAndhash
是alg
标头中指定的签名和哈希算法。 JOSE IANA页面包含支持算法的列表。
在上面的示例中,HS256使用SHA-256代表HMAC,而secret
是256位键。
签名是不是与加密相同的 - 即使没有加密密钥进行验证,任何人也可以解码令牌有效负载并检查内容。
任务:创建和验证已签名的JWT
假设我们已经任务构建一个系统,以安全地签名和验证身份验证的用户的JWT。我们将专注于构建一个小型HTTP服务器,该服务器公开了两个端点:一个用于基于某些用户信息生成签名的JWT,另一台用于验证现有JWT。我们将使用不对称的键来为此 - 意味着钥匙具有私有零件(用于签名)和公共部位(用于验证)。不对称密钥算法的示例包括RSA和ECDSA。
选择正确的算法
在开始之前,要了解要使用哪种签名算法以及利弊是至关重要的。如前所述,用于签名JWT的算法在alg
字段的标题中指定,并且有几种签名和哈希算法的选项。
让我们从哈希算法开始。 Hashing的最常见算法是SHA,而思考哈希安全性的直观方式是,每个人给您的安全级别是其输出尺寸的50%。因此,SHA-256将为您提供128位的安全性,SHA-512将为您提供256位的安全性。这意味着攻击者必须在发现碰撞之前生成2^128哈希。通常,以上256位以上的任何东西都可以接受。
涉及签名,从历史上看,RS256(RSASSA-PKCS1-V1_5)RS256一直是大多数JWT实现的默认值。与RSASSA-PKCS1-V1_5签名的JWTS具有确定性签名,这意味着相同的JWT标题和有效载荷将始终生成相同的签名。
即使对RSASSA-PKCS1-V1_5没有已知的攻击,但它的用法还是不鼓励使用椭圆曲线/ECDSA的。在某些情况下,例如对于UK Open Banking标准,禁止RSASSA-PKCS1-V1_5。
涉及ECDSA时,最常见的选择是ES256
,它使用P-256(椭圆曲线也称为SECP256R1)和SHA-256代表ECDE4。在强度方面,ECDA所需的键比RSA要短得多(您需要3072位才能使RSA键具有相同的P-256强度),并且密钥生成的速度比RSA快于RSA,即使验证通常较慢。
ecdsa使用一个随机的nonce,该非CE是每个签名生成的,因此ECDSA生成的签名是非确定性的。
随机nonce是关键,因为重用随机的nonce或在nonce中易于猜测的位可以使私钥易于恢复。索尼的PlayStation 3和比特币有两个引人注目的案例。在PlayStation 3案例中,由于静态NONCE而恢复了私钥,在比特币的情况下,Android用户由于Android上Java的SecureRandom
类中的错误而受到影响。
鉴于风险,选择一个库是通过使用RFC 6979所描述的确定性方案或以不取决于随机值的质量 - 例如,请参见this thread。
对于此博客,我们将使用ES256。如果您要实施自己的签名服务,请参考RFC 8725以获取当前的最佳实践。
创建,签名和验证JWT
对于此Blogpost,我们将使用Tink的Go library,但是有其他几种语言的已发布库,并且原理是相同的。
首先,让我们看一下Tink documentation on JWTs。首先,我们感兴趣的五种类型:
-
jwt.RawJWT
-未经验证的JWT -
jwt.VerifiedJWT
-已验证的JWT -
jwt.Signer
-签名JWTS 的接口
-
jwt.Verifier
-用于验证签名和编码JWTS的接口 -
keyset.Handle
-代表密码键集的类型,用于创建实现Signer
和Verifier
的实例
JWT的生命周期是
首先,我们要构建将签署的RawJWT
。这包括JWT specification中所述的注册索赔,以及一些自定义主张:
jwtID := uuid.New().String()
now := time.Now()
expiry := now.Add(tokenDuration)
opts := &jwt.RawJWTOptions{
Subject: &userID,
Issuer: &tokenIssuer,
JWTID: &jwtID,
IssuedAt: &now,
ExpiresAt: &expiry,
NotBefore: &now,
CustomClaims: claims,
}
rawJWT, err := jwt.NewRawJWT(opts)
if err != nil {
return "", fmt.Errorf("failed to create new RawJWT: %w", err)
}
现在我们有了我们的RawJWT
,我们可以签名并编码它。首先,我们需要一个签名者实现。
请注意,在示例中,
jwt
指的是Tink jwt package。
signingKeyset, err := tm.keysetsRepo.GetKeyset()
if err != nil {
return "", fmt.Errorf("failed to get token signing keyset: %w", err)
}
signer, err := jwt.NewSigner(signingKeyset)
if err != nil {
return "", fmt.Errorf("failed to create new Signer: %w", err)
}
为此,我们引入了一个界面,KeysetsRepo
:
type KeysetsRepo interface {
GetKeyset() (*keyset.Handle, error)
}
这是我们用来存储密钥组的任何界面。我们将稍后回到此详细信息 - 目前,我们可以简单地定义接口,这是获取密钥集的一种方法。一旦拥有该钥匙集,我们就可以创建一个新的JWT签名器。
并非所有钥匙集都可以用来创建
。Signer
-必须使用JWT库中定义的模板之一创建密钥集,如下所述。
最后,我们可以签署令牌并返回签名的令牌字符串:
signedToken, err := signer.SignAndEncode(rawJWT)
if err != nil {
return "", fmt.Errorf("failed to sign RawJWT: %w", err)
}
return signedToken, nil
所以现在我们有了签署JWT的整个方法。
现在,让我们实现生命周期的第二部分并验证令牌。首先,我们创建一个Verifier
实例:
verificationKeyset, err := tm.keysetsRepo.GetPublicKeyset()
if err != nil {
return nil, fmt.Errorf("failed to get token verification keyset: %w", err)
}
verifier, err := jwt.NewVerifier(verificationKeyset)
if err != nil {
return nil, fmt.Errorf("failed to create new Verifier: %w", err)
}
为此,我们为KeysetsRepo
接口添加了一种新方法,GetPublicKeyset
:
type KeysetsRepo interface {
GetKeyset() (*keyset.Handle, error)
GetPublicKeyset() (*keyset.Handle, error)
}
当我们使用非对称键签名时,我们处理了两个钥匙集 - 私人签名令牌,以及公众验证的钥匙。前者必须安全地存储并保密,否则任何人都可以签署令牌,就好像他们是您一样,使令牌验证毫无意义。后者可以出版,例如作为OIDC discovery document的一部分。在这种情况下,我们通过在存储库中有两种方法,一种用于获取完整钥匙的方法,而仅获得公共部分。
。我们还需要定义Validator
,可以用来检查令牌索赔:
opts := &jwt.ValidatorOpts{
ExpectedIssuer: &tokenIssuer,
IgnoreTypeHeader: true,
IgnoreAudiences: true,
ExpectIssuedInThePast: true,
}
validator, err := jwt.NewValidator(opts)
if err != nil {
return nil, fmt.Errorf("failed to create new Validator: %w", err)
}
我们在此示例中忽略了一些字段以使其短。
最后,我们可以解码和验证令牌:
verifiedJWT, err := verifier.VerifyAndDecode(signedToken, validator)
if err != nil {
return nil, InvalidTokenError
}
return verifiedJWT, nil
现在我们也有整个验证JWT的方法。
存储签名键
下一步是实现我们的KeysetsRepo
,即我们的服务之间的接口,但是我们坚持使用键。安全地坚持我们的令牌钥匙组至关重要 - 如果我们要失去它们,所有现有的令牌都需要无效,这可能会对使用该服务创建和验证令牌的任何人都会产生重大影响。
在此示例中,我们将实施一个非常简单的键集存储库,该存储库将密钥存储在本地文件系统中。尽管这通常不适合大规模分布式系统,但它可以说明持续和检索钥匙组的基本要点。密钥集将存储在工作目录中的单个文件中。在开始实施接口所需的方法之前,我们将编写一种初始化KeySet的方法,InitTokenKeyset
。
type FSKeysetsRepo struct {
keysetsFilePath string
masterKey tink.AEAD
}
func NewFSKeysetsRepo(keysetsFilePath string, masterKey tink.AEAD) *FSKeysetsRepo {
return &FSKeysetsRepo{
keysetsFilePath: keysetsFilePath,
masterKey: masterKey,
}
}
func (r *FSKeysetsRepo) InitTokenKeyset() error {
handle, err := keyset.NewHandle(jwt.ES256Template())
if err != nil {
return fmt.Errorf("failed to create new keyset handle with ES256 template: %w", err)
}
return r.writeKeysetToFile(handle)
}
func (r *FSKeysetsRepo) writeKeysetToFile(handle *keyset.Handle) error {
f, err := os.Create(r.keysetsFilePath)
if err != nil {
return fmt.Errorf("failed to create keysets file %s: %w", r.keysetsFilePath, err)
}
defer f.Close() // unhandled error
jsonWriter := keyset.NewJSONWriter(f)
err = handle.Write(jsonWriter, r.masterKey)
if err != nil {
return fmt.Errorf("failed to write keyset to file %s: %w", r.keysetsFilePath, err)
}
return nil
}
首先,我们使用jwt
软件包创建了使用keyset.NewHandle
和ES256Template
的新密钥组。这将使用ES256算法创建一个键,该算法实现了椭圆曲线与NIST P-256曲线签名。如上所述,这将创建一个适合创建Signer
和Verifier
实例的键。 Tink提供了许多其他无法用于签名/验证的密钥组模板,并且尝试使用一个模板将导致运行时错误。 Tink中的jwt
子包列出了可用的模板。
我们拥有新的Keyset句柄后,我们可以将其序列化为JSON并将其写入文件。
jsonWriter := keyset.NewJSONWriter(f)
err = handle.Write(jsonWriter, r.masterKey)
if err != nil {
return fmt.Errorf("failed to write keyset to file %s: %w", r.keysetsFilePath, err)
我们为文件创建一个JSONWriter
实例,然后调用Keyset handles Write
方法。请注意,这需要两个参数 - Writer
实现的实例,以及一个称为masterKey
的tink.AEAD
实例。这是用于加密密钥的加密密钥,然后将其写入文件。
Tink提供了
Writer
的其他各种实现;我们选择了JSON更加可读,因此可以检查Keyset文件。
现在我们可以创建一个密钥集并将其安全地存储在本地文件中。现在,我们可以返回实现KeysetsRepo
接口的方法。首先,GetKeyset
:
func (r *FSKeysetsRepo) GetKeyset() (*keyset.Handle, error) {
return r.readKeysetFromFile()
}
func (r *FSKeysetsRepo) readKeysetFromFile() (*keyset.Handle, error) {
f, err := os.Open(r.keysetsFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open keysets file %s: %w", r.keysetsFilePath, err)
}
defer f.Close() // unhandled error
jsonReader := keyset.NewJSONReader(f)
handle, err := keyset.Read(jsonReader, r.masterKey)
if err != nil {
return nil, fmt.Errorf("failed to read keyset as JSON: %w", err)
}
return handle, err
}
我们需要以后重新使用readKeysetFromFile
逻辑,因此我们将其提取到单独的方法。在这里,我们打开存放密钥集的文件,并创建一个JSONReader
(因为我们存储了序列化的密钥集为JSON)。然后,我们使用keyset.Read
方法来读取文件中的密钥集并创建一个keyset.Handle
。请注意,再次需要主键,这次解密密钥集。
GetPublicKeyset
方法非常相似,还有一个额外的步骤:
func (r *FSKeysetsRepo) GetPublicKeyset() (*keyset.Handle, error) {
handle, err := r.readKeysetFromFile()
if err != nil {
return nil, err
}
publicHandle, err := handle.Public()
if err != nil {
return nil, fmt.Errorf("failed to get public keyset: %w", err)
}
return publicHandle, nil
}
我们仅使用handle.Public()
方法获得KeySet的公共部分,该方法返回仅包含公共部位的新密钥集,然后我们返回。
API
我们服务的最后剩余部分是可用于创建和验证令牌的API。对于此示例,我们将实现一个非常基本的HTTP服务器,公开两个端点:
func StartServer(h *APIHandler) error {
mux := http.NewServeMux()
mux.HandleFunc("/tokens", h.PostToken)
mux.HandleFunc("/tokens/verify", h.PostVerifyToken)
return http.ListenAndServe(":8080", mux)
}
两个端点将仅接受POST
方法。 POST /tokens
将接受一些用户信息,并返回签名和编码的JWT; POST /tokens/verify
将接受签名的令牌并返回布尔值,指示其是否有效。
PostToken
可以利用GenerateTokenWithClaims
生成签名的令牌和PostVerifyToken
可以利用VerifyToken
验证令牌。
主键
我们的服务几乎完成了 - 我们拥有我们的API,是管理令牌的业务逻辑层,以及对钥匙组的持久性。是时候将所有这些放在一起:
func main() {
conf := getConfigFromEnv()
keysetsRepo := NewFSKeysetsRepo(
conf.keysetsFilePath,
masterKey,
)
tokenManager := NewTinkTokenManager(keysetsRepo)
apiHandler := NewAPIHandler(tokenManager)
err := keysetsRepo.InitTokenKeyset()
if err != nil {
log.Fatalf("Failed to initialize token keysets")
}
err = StartServer(apiHandler)
if err != nil {
log.Fatalf("Server encountered an error: %s", err.Error())
}
}
我们现在不必太担心从环境变量获取配置 - 但是,我们缺少主键,我们需要构造我们的按键。
如上所述,主键用于加密您的签名键,因此必须安全,安全地保持它至关重要。如果您要丢失主键,那么所有签名钥匙组将变得无法访问和无法使用。因此,建议您将主密钥存储在安全密钥管理服务(KMS)中。云提供商(例如GCP,AWS)将KMS作为功能提供,因此可以作为云部署的一部分集成。
对于本地测试,可以在本地文件中创建密钥组并将其存储在纯文本中。示例回购包含执行此操作的代码,以帮助您在本地尝试该服务。但是,对于非测试场景,这是不安全的!
我们将以GCP KMS为例:
func getMasterKey(conf *config) tink.AEAD {
gcpClient, err := gcpkms.NewClientWithOptions(context.Background(), conf.keyURIPrefix)
if err != nil {
log.Fatal(err)
}
registry.RegisterKMSClient(gcpClient)
masterKey, err := gcpClient.GetAEAD(conf.masterKeyURI)
if err != nil {
log.Fatalf("Failed to retrieve master key: %s", err.Error())
}
return masterKey
}
masterKey
我们返回这里实现了tink.AEAD
接口进行加密和解密。但是,我们在本地没有主密钥本身 - 必须安全地保存在公里。相反,要加密/解密的数据是从幕后的KMS传输的。
结论
在此博客文章中,我们提出了开始使用JWT的Tink的基本步骤,包括:
- 创建用于签名和验证键的Tink Keyset
- 安全存储这些钥匙
- 使用JWTS的签名和验证器接口
- 使用kms存储主键
我们的下一个系列博客文章将基于此基础,研究密钥管理(尤其是密钥旋转),并更深入地深入研究Tink Keyset的结构。