使用Google Tink与ECDSA签名
#安全 #go #体系结构 #jwt

介绍

在这篇博客文章中,我们将展示如何使用Tink Cryptography库来创建,签名和验证JSON Web令牌(JWTS),以及管理加密密钥进行此操作。这是一个更实用的例子,以补充Tink自己关于基础加密理论及其方法的文档。

SlashID,我们是Tink的忠实拥护者,并以各种角色使用它,包括签名令牌!在我们的blog

上阅读有关它的更多信息。

背景

由于三个核心特征,我们选择了丁克:

  • 易于使用的API的淡淡理念很难搞砸,并且使用安全的默认值 - 我们将在下面看到错误的库如何使您的令牌服务完全不安全
  • 钥匙旋转和管理盒
  • Tink加密API和与KMS的集成使得易于保护签名密钥

在我们深入研究教程之前,让我们简要介绍JWTS。

JSON网络令牌

JWT是一种行业标准,广泛用于身份,身份验证和用户管理领域。它们用于以可验证的方式在两方之间传输信息。 JWT有许多出色的介绍,因此为了讨论的目的,我们将重点关注结构。

JWT通常以基本-64编码字符串的形式传输,并由由周期分开的三个部分组成:

  1. 一个包含关于令牌本身的元数据的标题
  2. 有效载荷,一套由JSON形式的索赔
  3. 可用于验证有效载荷内容的签名

例如,这个JWT

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvZSBTbGFzaElEIiwiaWF0IjoxNTE2MjM5MDIyfQ.4cL42NsNCXLPEvmvNGxHN3wLuarpp98wwezHnSt2fqg

有以下部分

的有效载荷> 的HS256算法生成的签名
部分 编码值 解码值 描述
标题 eyjhbgcioijiuzi1niisinr5cci6ikpxvcj9​​ {“ alg”:“ hs256”,“ typ”:“ jwt”} 表示这是JWT,并且使用HS256算法(使用SHA-256的HMAC)hash hash
有效载荷 eyjzdwiioiixmjmjm0nty3odkwiiwibmftzsi6ikpvzsbtbgfzaeleiiiwiawiawiawijoxnte2mjmjmjm5mdiyfq {“ sub”:“ 1234567890”,“名称”:“ slashid用户”,“ IAT”:1516239022} 有效载荷,有关用户和令牌
签名 4cl42nsnsncxlpevmvngxhn3wluarpp98wwezhnst2fqg n/a 使用验证有效载荷

重要的方面是JWT是签名的,这意味着有效载荷中的主张可以验证,如果可以访问适当的加密密钥。

JWT令牌的签名计算如下:

signAndhash(base64UrlEncode(header) + '.' + base64UrlEncode(payload))

signAndhashalg标头中指定的签名和哈希算法。 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-代表密码键集的类型,用于创建实现SignerVerifier的实例

JWT的生命周期是

SlashID Documentation Site

首先,我们要构建将签署的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.NewHandleES256Template的新密钥组。这将使用ES256算法创建一个键,该算法实现了椭圆曲线与NIST P-256曲线签名。如上所述,这将创建一个适合创建SignerVerifier实例的键。 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实现的实例,以及一个称为masterKeytink.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的结构。