k8s の力を借りてJWK自動更新くんを作ってみる

公開:2020/12/20
更新:2020/12/21
18 min読了の目安(約17000字TECH技術記事

この記事は 富士通クラウドテクノロジーズ Advent Calendar 2020 20日目の記事です。

こんにちは!
conhumi と申します。
zenn.dev 初投稿になります!よろしくお願いいたします!

3行で

  • JWK を Kubernetes の Secret として管理しています
  • JWK を有効期限付きで管理する仕組みを Kubernetes のパワーを借りて作りました
  • Kubebuilder 便利

はじめに

仕事で JWT を使って署名付きで情報を送付するアプリケーションを作っているのですが、作業を進める中で、署名に使う鍵の管理をどのように行うかが議論になりました。

下記のような候補が上がったのですが、

  • 設定ファイル
  • セルフホストできるKMSの利用(HashiCorp Vaultなど)

セルフホストできるKMSを運用するリソースが無いため、今はアプリケーション実行環境である Kubernetes の Secret として秘密鍵を保持しています(一応設定ファイル形式)。

Secret であれば SealedSecret の仕組み等を使えば鍵の情報を安全に GitOps で管理できますね。

ところで、HashiCorp Vault などの外部KMSには有効期限を設定する機能があり、定期的に鍵を更新するようなユースケースにも対応できそうなのですが、Kubernetes の Secret にはそういった機能はもちろんついていません。

そこで、Kubernetes の勉強もかねて、JWK に有効期限をつけて管理する仕組みを Kubernetes で実現してみたので、その様子を書き残したいと思います。

やりたいこと

  • Secret で JWK を管理できる
  • JWK(Secret) に有効期限を設けて、有効期限が切れたら JWK を削除できる
  • JWK(Secret) の更新期間を設定して、有効期限が切れる前に更新用 JWK を生成できる

上記のような要件を満たす仕組みを Kubernetes で作ってみます。

設計

上記のような仕組みを Kubernetes で実現するために、下記のような仕組みを考えました。

期限付きJWKSpec/期限付きJWKStatus を Kubernetes の CR(CustomResoruce) として定義します。
そして、コントローラが下記のような形で JWK を管理します。

  • 有効期限内の JWK が無い場合は新規作成(JWKを作成しSecretとして保存)
    • 必要に応じて有効期限を設定
  • 有効期限切れの JWK があったら Secret を削除
  • 有効期限内でも更新期間のJWKがあった場合
    • 他に有効期間内で更新期間ではないJWKがあれば何もしない
    • 他に有効期間内で更新期間ではないJWKがなければ新規作成(JWKを作成しSecretとして保存)

簡単ですが、上記のような設計を実現しました。

実際に実装するにあたっては、CRとコントローラを簡単に作成できる Kubebuilder を利用します。

作業記録

材料

  • kind
    • 自前でちゃんとした Kubernetes の実行環境を用意するのは大変です。
    • インストール方法は こちら
  • kubebuilder
    • 今回のメインとなる材料です。
    • インストール方法は こちら

上記2つのツールがインストール出来たところからの作業記録となります。

プロジェクトディレクトリの準備

適当な空のディレクトリで kubebuilder init を実行しようとしたら、 GOPATH 配下じゃないね。モジュール名決めて(意訳) と怒られたので、 go mod init をしてモジュール名を決めておきます。

go mod init github.com/conhumi/key-update-controller

go.mod ファイルができました。

kubebuilder init

kubebuilder init --domain conhumi.net

--domain オプションには、好きな名前を指定します。なんでも良いです。
kubernetes にリソースを登録する際、 APIVersion の中に設定値が入ります。

kubebuilder create api

kubebuilder create api --group juc --version v1alpha1 --kind JWK

--group--version オプションの設定値も APIVersion の一部になります。
--kind オプションの設定値は、読んで字のごとく、Kubernetes の CR の名前となります。

APIVersion は <group>.<domain>/<version> という形になりますので、上記の例だと、
juc.conhumi.net/v1alpha1 となります。

kubebuilder でのテンプレート作成作業はココで終わりです。
いよいよ API 定義とController を作成していきます。

API 定義を作成

プロジェクトルートから api/v1alpha1/jwk_types.go と辿ったファイルを編集します。
上述した設計を元に、下記のようなAPI定義を作成しました。

package v1alpha1

import (
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// JWKSpec defines the desired state of JWK
type JWKSpec struct {
	KeyType       string `json:"keyType"`
	PublicKeyUse  string `json:"publicKeyUse,omitempty"`
	KeyOperations string `json:"keyOperations,omitempty"`

	ExpireDurationHours              int32 `json:"expireDurationHours,omitempty"`
	RotateDurationHoursBeforeExpired int32 `json:"rotateDurationHoursBeforeExpired,omitempty"`

	// Parameters for 'EC' KeyType
	CurveParameter string `json:"curveParameter,omitempty"`

	// Parameters for 'RSA' KeyType
	RSABitLength int32 `json:"rsaBitLength,omitempty"`
}

// JWKStatus defines the observed state of JWK
type JWKStatus struct {
	JWKs []Secret `json:"jwks,omitempty"`
}

// Secret defines the state of JWK Stored secret
type Secret struct {
	SecretRef corev1.ObjectReference `json:"secretRef,omitempty"`
	ExpireAt  string                 `json:"expireAt,omitempty"`
}

// +kubebuilder:object:root=true

// JWK is the Schema for the jwks API
type JWK struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   JWKSpec   `json:"spec,omitempty"`
	Status JWKStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true

// JWKList contains a list of JWK
type JWKList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`
	Items           []JWK `json:"items"`
}

func init() {
	SchemeBuilder.Register(&JWK{}, &JWKList{})
}

主に編集するのは、 JWKSpecJWKStatus の中身と独自定義した構造体です。
編集が終わったら make コマンドを実行すると API 定義を Kubernetes に適用するための CRD を作成してくれます。(API定義に問題がある場合は指摘をしてくれるので修正します)

Controller の作成

プロジェクトルートから controllers/jwk_controller.go と辿ったファイルを編集します。

基本的には Reconcile メソッドに処理を書いていけばいいようです。

package controllers

import (
	"context"
	"crypto/ecdsa"
	"crypto/ed25519"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/rsa"
	"encoding/json"
	"fmt"
	"time"

	"github.com/go-logr/logr"
	"github.com/lestrrat-go/jwx/jwk"
	"github.com/pkg/errors"
	corev1 "k8s.io/api/core/v1"
	k8serrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"

	jucv1alpha1 "github.com/conhumi/jwk-update-controller/api/v1alpha1"
)

const parseTimeLayout = time.RFC3339

// JWKReconciler reconciles a JWK object
type JWKReconciler struct {
	client.Client
	Log    logr.Logger
	Scheme *runtime.Scheme
}

func (r *JWKReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&jucv1alpha1.JWK{}).
		Owns(&corev1.Secret{}).
		Complete(r)
}

// +kubebuilder:rbac:groups=juc.conhumi.net,resources=jwks,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=juc.conhumi.net,resources=jwks/status,verbs=get;update;patch

func (r *JWKReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
	ctx := context.Background()
	r.Log = r.Log.WithValues("jwk", req.NamespacedName)

	object := jucv1alpha1.JWK{}
	if err := r.Client.Get(ctx, req.NamespacedName, &object); err != nil {
		if k8serrors.IsNotFound(err) {
			return ctrl.Result{}, nil
		}
		r.Log.Error(err, "get jwk resource failed", "namespacedName", req.NamespacedName)
		return ctrl.Result{}, err
	}

	desire := object.DeepCopy()

	validJWKs, err := r.getValidJWKs(ctx, object.Status)
	if err != nil {
		r.Log.Error(err, "get valid jwks failed")
		return ctrl.Result{}, err
	}

	expiredJWKs, err := r.getExpiredJWKs(ctx, object.Status)
	if err != nil {
		r.Log.Error(err, "get expired jwks failed")
		return ctrl.Result{}, err
	}

	renewalNeededJWKs, err := r.getRenewalNeededJWKs(ctx, object)
	if err != nil {
		r.Log.Error(err, "get renewal needed jwks failed")
		return ctrl.Result{}, err
	}

	if len(expiredJWKs) > 1 {
		for _, expiredJWK := range expiredJWKs {
			if err := r.deleteJWKSecret(ctx, expiredJWK); err != nil &&
				!k8serrors.IsNotFound(err) {
				r.Log.Error(err, "delete expired jwk failed")
				return ctrl.Result{}, err
			}
		}
		desire.Status.JWKs = validJWKs
		return ctrl.Result{}, r.Client.Update(ctx, desire)
	}

	if len(validJWKs) < 1 || len(validJWKs) == len(renewalNeededJWKs) {
		r.Log.Info("create new jwk")
		newJWK, err := r.createJWKSecret(ctx, desire.ObjectMeta, desire.Spec)
		if err != nil {
			r.Log.Error(err, "create jwk failed", "spec", desire.Spec)
			return ctrl.Result{}, err
		}
		desire.Status.JWKs = append(desire.Status.JWKs, newJWK)
		return ctrl.Result{}, r.Client.Update(ctx, desire)
	}

	return ctrl.Result{Requeue: true, RequeueAfter: 30 * time.Second}, nil
}

func (r *JWKReconciler) getValidJWKs(ctx context.Context, jwkStatus jucv1alpha1.JWKStatus) ([]jucv1alpha1.Secret, error) {
	validJWKs := []jucv1alpha1.Secret{}
	for _, jwkSecret := range jwkStatus.JWKs {
		if jwkSecret.ExpireAt != "" {
			expireAt, err := time.Parse(parseTimeLayout, jwkSecret.ExpireAt)
			if err != nil {
				r.Log.Error(err, "parse expiredAt failed", "value", jwkSecret.ExpireAt)
				return []jucv1alpha1.Secret{}, err
			}
			if time.Now().After(expireAt) {
				continue
			}
		}
		validJWKs = append(validJWKs, jwkSecret)
	}
	return validJWKs, nil
}

func (r *JWKReconciler) getExpiredJWKs(ctx context.Context, jwkStatus jucv1alpha1.JWKStatus) ([]jucv1alpha1.Secret, error) {
	expiredJWKs := []jucv1alpha1.Secret{}
	for _, jwkSecret := range jwkStatus.JWKs {
		expireAt, err := time.Parse(parseTimeLayout, jwkSecret.ExpireAt)
		if err != nil {
			r.Log.Error(err, "parse expiredAt failed", "value", jwkSecret.ExpireAt)
			return []jucv1alpha1.Secret{}, err
		}
		if time.Now().After(expireAt) {
			expiredJWKs = append(expiredJWKs, jwkSecret)
		}
	}
	return expiredJWKs, nil
}

func (r *JWKReconciler) getRenewalNeededJWKs(ctx context.Context, jwk jucv1alpha1.JWK) ([]jucv1alpha1.Secret, error) {
	renewalNeededJWKs := []jucv1alpha1.Secret{}
	for _, jwkSecret := range jwk.Status.JWKs {
		expireAt, err := time.Parse(parseTimeLayout, jwkSecret.ExpireAt)
		if err != nil {
			r.Log.Error(err, "parse expiredAt failed", "value", jwkSecret.ExpireAt)
			return []jucv1alpha1.Secret{}, err
		}
		rotateAt := expireAt.Add(time.Duration(-1*jwk.Spec.RotateDurationHoursBeforeExpired) * time.Hour)
		if time.Now().After(rotateAt) {
			renewalNeededJWKs = append(renewalNeededJWKs)
		}
	}
	return renewalNeededJWKs, nil
}

func (r *JWKReconciler) createJWKSecret(
	ctx context.Context,
	objMeta metav1.ObjectMeta,
	jwkSpec jucv1alpha1.JWKSpec) (jucv1alpha1.Secret, error) {
	createdAt := time.Now()

	privateKey, err := generatePrivateKey(jwkSpec)
	if err != nil {
		r.Log.Error(err, "create private key failed")
		return jucv1alpha1.Secret{}, err
	}

	privateKeyJWK, err := jwk.New(privateKey)
	if err != nil {
		r.Log.Error(err, "convert jwk failed")
		return jucv1alpha1.Secret{}, err
	}

	if jwkSpec.PublicKeyUse != "" {
		if err := privateKeyJWK.Set(jwk.KeyUsageKey, jwkSpec.PublicKeyUse); err != nil {
			r.Log.Error(err, "set public key use failed")
			return jucv1alpha1.Secret{}, err
		}
	}
	if jwkSpec.KeyOperations != "" {
		if err := privateKeyJWK.Set(jwk.KeyOpsKey, jwkSpec.KeyOperations); err != nil {
			r.Log.Error(err, "set public key use failed")
			return jucv1alpha1.Secret{}, err
		}
	}

	privateKeyJWKJson, err := json.Marshal(privateKeyJWK)
	if err != nil {
		r.Log.Error(err, "convert json failed")
		return jucv1alpha1.Secret{}, err
	}

	secretName := fmt.Sprintf("%s-%s", objMeta.Name, createdAt.Format("2006-01-02-15-04-05"))
	newSecret := corev1.Secret{
		ObjectMeta: metav1.ObjectMeta{
			Namespace: objMeta.Namespace,
			Name:      secretName,
		},
		StringData: map[string]string{
			"privateKeyJWK": string(privateKeyJWKJson),
		},
	}
	if err := r.Client.Create(ctx, &newSecret); err != nil {
		return jucv1alpha1.Secret{}, err
	}
	expireAt := ""
	if jwkSpec.ExpireDurationHours != 0 {
		expireAt = time.Now().Add(
			time.Duration(jwkSpec.ExpireDurationHours) * time.Hour,
		).Format(parseTimeLayout)
	}
	return jucv1alpha1.Secret{
		SecretRef: corev1.ObjectReference{
			Namespace: objMeta.Namespace,
			Name:      secretName,
		},
		ExpireAt: expireAt,
	}, nil
}

func generatePrivateKey(jwkSpec jucv1alpha1.JWKSpec) (interface{}, error) {
	switch jwkSpec.KeyType {
	case "EC":
		return generateECPrivateKey(jwkSpec.CurveParameter)
	case "RSA":
		return generateRSAPrivateKey(jwkSpec.RSABitLength)
	default:
		return nil, errors.Errorf("unknown keyt type: %s", jwkSpec.KeyType)
	}
}

func generateECPrivateKey(curveParameter string) (interface{}, error) {
	switch curveParameter {
	case "P256", "P-256":
		return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	case "P384", "P-384":
		return ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
	case "P521", "P-521":
		return ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
	case "Ed25519":
		_, pri, err := ed25519.GenerateKey(rand.Reader)
		return pri, err
	default:
		return nil, errors.Errorf("Unknown Elliptic Curve: %s", curveParameter)
	}
}

func generateRSAPrivateKey(bits int32) (interface{}, error) {
	if bits < 512 || bits > 8192 {
		return nil, errors.Errorf("Invalid RSA key size: %d", bits)
	}
	return rsa.GenerateKey(rand.Reader, int(bits))
}

func (r *JWKReconciler) deleteJWKSecret(ctx context.Context, jwkSecret jucv1alpha1.Secret) error {
	secret := corev1.Secret{}
	nsn := types.NamespacedName{
		Namespace: jwkSecret.SecretRef.Namespace,
		Name:      jwkSecret.SecretRef.Name,
	}
	if err := r.Client.Get(ctx, nsn, &secret); err == nil {
		if err := r.Client.Delete(ctx, &secret); err != nil {
			return err
		}
	}
	return nil
}

func unsetJWK(s []jucv1alpha1.Secret, i int) []jucv1alpha1.Secret {
	if i >= len(s) {
		return s
	}
	return append(s[:i], s[i+1:]...)
}

実行

kind のクラスタを構築してない場合は、 kind create cluster コマンドで k8s 実行環境を用意しておきます。
kubectl cluster-info でクラスタの情報が返却されることを確認したら、 make install コマンドで CRD を適用し設計した CR をクラスタに作成します。
その後、 make run コマンドで Controller を実行します。

$ make run
/home/sci01559/go/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
go vet ./...
/home/sci01559/go/bin/controller-gen "crd:trivialVersions=true" rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases
go run ./main.go
2020-12-21T00:53:18.362+0900    INFO    controller-runtime.metrics      metrics server is starting to listen    {"addr": ":8080"}
2020-12-21T00:53:18.362+0900    INFO    setup   starting manager
2020-12-21T00:53:18.362+0900    INFO    controller-runtime.manager      starting metrics server {"path": "/metrics"}
2020-12-21T00:53:18.362+0900    INFO    controller-runtime.controller   Starting EventSource    {"controller": "jwk", "source": "kind source: /, Kind="}
2020-12-21T00:53:18.463+0900    INFO    controller-runtime.controller   Starting EventSource    {"controller": "jwk", "source": "kind source: /, Kind="}
2020-12-21T00:53:18.563+0900    INFO    controller-runtime.controller   Starting Controller     {"controller": "jwk"}
2020-12-21T00:53:18.563+0900    INFO    controller-runtime.controller   Starting workers        {"controller": "jwk", "worker count": 1}
2020-12-21T00:53:24.881+0900    INFO    controllers.JWK create new jwk  {"jwk": "default/jwk-sample"}
2020-12-21T00:53:24.900+0900    DEBUG   controller-runtime.controller   Successfully Reconciled {"controller": "jwk", "request": "default/jwk-sample"}
2020-12-21T00:53:32.117+0900    DEBUG   controller-runtime.controller   Successfully Reconciled {"controller": "jwk", "request": "default/jwk-sample"}
2020-12-21T00:53:54.900+0900    DEBUG   controller-runtime.controller   Successfully Reconciled {"controller": "jwk", "request": "default/jwk-sample"}
2020-12-21T00:53:55.693+0900    INFO    controllers.JWK create new jwk  {"jwk": "default/jwk-sample", "jwk": "default/jwk-sample", "jwk": "default/jwk-sample", "jwk": "default/jwk-sample", "jwk": "default/jwk-sample"}
2020-12-21T00:53:55.701+0900    DEBUG   controller-runtime.controller   Successfully Reconciled {"controller": "jwk", "request": "default/jwk-sample"}

こんな出力になりました。

※本番環境で動作させる場合は Controller をコンテナイメージとして Kubernetes にデプロイするようですが今回は割愛します。

下記のようなサンプルリソースを作成して、Controller の動作を確認しました。

apiVersion: juc.conhumi.net/v1alpha1
kind: JWK
metadata:
  name: jwk-sample
spec:
  keyType: EC
  curveParameter : P256
  publicKeyUse: "sig"
  expireDurationHours: 2
  rotateDurationHoursBeforeExpired: 1

すると、 Controller が動作して下記のように Status に値が追加され、追加された名前のSecretも作成されていました。
実際に中身を見てみると、それっぽいJWK形式の値が入っていることも確認できました。
(kid が無いですね... JWK 生成ロジックに見直しが必要です...)

$ kubectl describe JWK
Name:         jwk-sample
Namespace:    default
Labels:       <none>
Annotations:  <none>
API Version:  juc.conhumi.net/v1alpha1
Kind:         JWK
Metadata:
  Creation Timestamp:  2020-12-20T15:53:55Z
  Generation:          2
  Resource Version:  254511
  Self Link:         /apis/juc.conhumi.net/v1alpha1/namespaces/default/jwks/jwk-sample
  UID:               17ee88e3-3e79-453b-bd03-58bfa10c28e8
Spec:
  Curve Parameter:                       P256
  Expire Duration Hours:                 2
  Key Type:                              EC
  Public Key Use:                        sig
  Rotate Duration Hours Before Expired:  1
Status:
  Jwks:
    Expire At:  2020-12-21T02:53:55+09:00
    Secret Ref:
      Name:       jwk-sample-2020-12-21-00-53-55
      Namespace:  default
Events:           <none>
$ kubectl describe secrets jwk-sample-2020-12-21-00-53-55
Name:         jwk-sample-2020-12-21-00-53-55
Namespace:    default
Labels:       <none>
Annotations:  <none>

Type:  Opaque

Data
====
privateKeyJWK:  188 bytes
$ kubectl get secrets jwk-sample-2020-12-21-00-53-55 -o jsonpath='{.data.privateKeyJWK}' | base64 -d | jq
{
  "kty": "EC",
  "crv": "P-256",
  "d": "ixg8Yzewl9yOoDUxZHCbqBimlmYMz2a3hsLiXBSFJOQ",
  "use": "sig",
  "x": "H2zN7qlFahK_sfNSZr9r-djWPztrGcBqNridWcyC48g",
  "y": "1Ek9eofQxYgcdq5O9itfzA-9CkHhSKjllzd7bkABdN8"
}

まとめ

Kubebuilder を使って、 Kubernetes 上に独自のリソースを定義し、Controller を使って自動的にリソース管理する仕組みを作ってみました。

今回はJWKを題材にしましたが、有効期限をつけて更新や削除するという作業は他にも存在しそうだなと感じます。
一般化してみると、「何かの状態を監視して状態に応じた処理を行う」ということはシステム運用業務の中でよく発生している動きだと感じました。

Kuberenetes や Kubernetes上で動作するアプリケーションの開発・運用の中で、上記のような作業をアプリケーションに落とし込むことで、いろいろなことが自動化出来るように感じます。

他にも自動化できることを見つけたら Controller を作ってみたいなと思いました。

以上、誤りや勘違い等ございましたら、お手柔らかにご指摘頂けますと幸いです。