GitHub App の秘密鍵を KMS に保存して JWT の署名に使ってみる

これは [CAMPFIRE Advent Calendar 2024](https://qiita.com/advent-calendar/2024/campfire) 5 日目の記事です。

## はじめに

GitHub アプリで API の認証を行う場合、JSON Web Token (JWT) を生成する必要があります。
その JWT の署名に必要な秘密鍵は GitHub からダウンロードできますが、これを AWS Key Management Service (KMS) に登録して、KMS によって JWT の署名が可能か試してみました。

また、JavaScript SDK の [octokit.js](https://github.com/octokit/octokit.js) と KMS を組み合わせて使う方法についても試しました。

## 秘密鍵を直接使わず KMS 経由で署名をしたい理由

[GitHub Apps の秘密キーの管理](https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps#storing-private-keys) を読むと、

> 秘密キーは、GitHub App にとって最も重要な唯一のシークレットです。 [Azure Key Vault](https://azure.microsoft.com/en-gb/products/key-vault) のようなキー コンテナーにキーを格納し、署名専用にすることを検討します。 これにより、秘密キーを紛失することがないようにします。 秘密キーはキー コンテナーにアップロードされると、そこから読み取ることはできなくなります。 秘密キーは署名にのみ使用でき、秘密キーへのアクセスはインフラストラクチャのルールによって決定されます。
> 
> 
> または、キーを環境変数として格納することもできます。 これは、キー コンテナーにキーを格納するほど強力ではありません。 攻撃者が環境へのアクセス権を獲得した場合、秘密キーを読み取り GitHub App として永続的な認証を受けることができます。
> 

とあります。

Azure が出てくるあたり親会社の意向を感じますが、AWS で言えば KMS に該当します。これができれば、キーをアプリに環境変数で渡してアプリ内で署名する方式に比べて以下のメリットがあります。

- 秘密鍵は有効期限がないので漏洩した時の影響が大きいが、KMS だと一度格納すると二度と取り出せなくなるので鍵の漏洩リスクが実質的になくせる
- 署名できる権限は IAM で制御できるようになる。特にアプリを AWS 上で実行する場合 IAM ロールで権限付与できるのでクレデンシャル管理が要らなくなる

つまりめっちゃセキュアになります。

一方で以下の点については場合によってはデメリットになり得ます。

- Octokit の初期化がめんどくさい (後述)
- ネットワーク経由で署名するので、ローカルで署名するのに対して当然パフォーマンスは落ちる
- アプリケーションに AWS SDK が必要

## GitHub App からダウンロードした秘密鍵 (.pemファイル) を KMS にインポートする

[Importing key material for AWS KMS keys](https://docs.aws.amazon.com/ja_jp/kms/latest/developerguide/importing-keys.html) に従ってインポートしました。

### [ステップ 1: キーマテリアルなしで KMS キーを作成する](https://docs.aws.amazon.com/ja_jp/kms/latest/developerguide/importing-keys-create-cmk.html)

カスタマー管理型のキー > キーの作成

**キーのタイプ:** 非対称
**キーの使用法:** 署名および検証
**キーの仕様:** RSA_2048
**キーマテリアルオリジン:** 外部（キーマテリアルのインポート）
**リージョン:** 単一リージョンキー

### [ステップ 2: ラップパブリックキーおよびインポートトークンのダウンロード](https://docs.aws.amazon.com/ja_jp/kms/latest/developerguide/importing-keys-get-public-key-and-token.html)

**ラップキー仕様:** RSA_4096
**ラップアルゴリズム:** RSA_AES_KEY_WRAP_SHA_256

ダウンロードした zip を展開すると、以下のファイルがある

- README.txt
- WrappingPublicKey.bin パブリックキー (ステップ 3 で使う)
- ImportToken.bin インポートトークン (ステップ 4 で使う)

### [ステップ 3: キーマテリアルを暗号化する](https://docs.aws.amazon.com/ja_jp/kms/latest/developerguide/importing-keys-encrypt-key-material.html)

まず、GitHubからダウンロードした pem ファイルをバイナリに変換して**キーマテリアル** (RSA_2048_PrivateKey.der) として使う

```bash
$ openssl rsa -in "GitHubからダウンロードした.pem" -out RSA_2048_PrivateKey.der -outform der
```

256 ビットAES対称暗号化キー (aes-key.bin) を生成する

```bash
# Generate a 32-byte AES symmetric encryption key
$ openssl rand -out aes-key.bin 32
```

AES 対称暗号化キーを使用してキーマテリアルを暗号化する (key-material-wrapped.bin)

```bash
# Encrypt your key material with the AES symmetric encryption key
$ openssl enc -id-aes256-wrap-pad \
        -K "$(xxd -p < aes-key.bin | tr -d '\n')" \
        -iv A65959A6 \
        -in RSA_2048_PrivateKey.der \
        -out key-material-wrapped.bin
```

AES パブリックキーを使用して対称暗号化キーを暗号化する (aes-key-wrapped.bin)

```bash
# Encrypt your AES symmetric encryption key with the downloaded public key
$ openssl pkeyutl \
    -encrypt \
    -in aes-key.bin \
    -out aes-key-wrapped.bin \
    -inkey WrappingPublicKey.bin \
    -keyform DER \
    -pubin \
    -pkeyopt rsa_padding_mode:oaep \
    -pkeyopt rsa_oaep_md:sha256 \
    -pkeyopt rsa_mgf1_md:sha256
```

インポートするファイルを生成する (EncryptedKeyMaterial.bin)

```bash
# Combine the encrypted AES key and encrypted key material in a file
$ cat aes-key-wrapped.bin key-material-wrapped.bin > EncryptedKeyMaterial.bin
```

### [ステップ 4: キーマテリアルのインポート](https://docs.aws.amazon.com/ja_jp/kms/latest/developerguide/importing-keys-import-key-material.html)

ステップ 3 の EncryptedKeyMaterial.bin → **ラップされたキーマテリアル**にアップロード

ステップ 2 の ImportToken.bin → **トークンのインポート**にアップロード

### 確認

マネコンで登録したキーを選んで「パブリックキー」タブを表示すると、pem ファイルの公開鍵と一致しているか確認できる

```bash
# 公開鍵の表示
$ openssl rsa -in "GitHubからダウンロードした.pem" -pubout
```

### 全部一発でやるシェルスクリプト

ドキュメントに CLI からやる方法も書いてあったので、シェルスクリプトにまとめました。

```bash:upload_github_app_key_to_kms.sh
#!/usr/bin/env bash
#
# GitHub App の秘密鍵を KMS のカスタマーキーとして登録するスクリプト
# Usage: upload_github_app_key_to_kms.sh /path/to/github_app_private_key.pem [kms_alias]
#

set -euo pipefail

# ダウンロードした秘密鍵のパス
PRIVATE_KEY_PEM=$1
# KMS キーに設定するエイリアス (省略可)
KMS_ALIAS=${2:-}

# 使用する中間ファイルのパス
wrapping_public_key_bin=$(mktemp wrapping_public_key_bin.XXXXX)
import_token_bin=$(mktemp import_token_bin.XXXXX)
private_key_bin=$(mktemp private_key_bin.XXXXX)
aes_key_bin=$(mktemp aes_key_bin.XXXXX)
key_material_wrapped_bin=$(mktemp key_material_wrapped_bin.XXXXX)
aes_key_wrapped_bin=$(mktemp aes_key_wrapped_bin.XXXXX)
encrypted_key_material_bin=$(mktemp encrypted_key_material_bin.XXXXX)

function rm_tmpfile() {
  rm -f "$wrapping_public_key_bin" \
    "$import_token_bin" \
    "$private_key_bin" \
    "$aes_key_bin" \
    "$key_material_wrapped_bin" \
    "$aes_key_wrapped_bin" \
    "$encrypted_key_material_bin"
}

trap rm_tmpfile EXIT

###
### ステップ 1: キーマテリアルなしで KMS キーを作成する
### https://docs.aws.amazon.com/ja_jp/kms/latest/developerguide/importing-keys-create-cmk.html
###

kms_key=$(aws kms create-key \
  --key-usage SIGN_VERIFY \
  --origin EXTERNAL \
  --key-spec RSA_2048
)
key_id=$(echo "$kms_key" | jq -r .KeyMetadata.KeyId)

###
### ステップ 2: ラップパブリックキーおよびインポートトークンのダウンロード
### https://docs.aws.amazon.com/ja_jp/kms/latest/developerguide/importing-keys-get-public-key-and-token.html
###

import_params=$(aws kms get-parameters-for-import \
  --key-id "$key_id" \
  --wrapping-algorithm RSA_AES_KEY_WRAP_SHA_256 \
  --wrapping-key-spec RSA_4096
)

echo "$import_params" | jq -r .PublicKey | openssl enc -d -base64 -A -out "$wrapping_public_key_bin"
echo "$import_params" | jq -r .ImportToken | openssl enc -d -base64 -A -out "$import_token_bin"

###
### ステップ 3: キーマテリアルを暗号化する
### https://docs.aws.amazon.com/ja_jp/kms/latest/developerguide/importing-keys-encrypt-key-material.html
###

# GitHub からダウンロードした pem ファイルをバイナリに変換
openssl rsa -in "$PRIVATE_KEY_PEM" -out "$private_key_bin" -outform der

# 256 ビットAES対称暗号化キー (aes-key.bin) を生成する
# Generate a 32-byte AES symmetric encryption key
openssl rand -out "$aes_key_bin" 32

# AES 対称暗号化キーを使用してキーマテリアルを暗号化する (key-material-wrapped.bin)
# Encrypt your key material with the AES symmetric encryption key
openssl enc -id-aes256-wrap-pad \
  -K "$(xxd -p < "$aes_key_bin" | tr -d '\n')" \
  -iv A65959A6 \
  -in "$private_key_bin" \
  -out "$key_material_wrapped_bin"

# AES パブリックキーを使用して対称暗号化キーを暗号化する (aes-key-wrapped.bin)
# Encrypt your AES symmetric encryption key with the downloaded public key
openssl pkeyutl \
  -encrypt \
  -in "$aes_key_bin" \
  -out "$aes_key_wrapped_bin" \
  -inkey "$wrapping_public_key_bin" \
  -keyform DER \
  -pubin \
  -pkeyopt rsa_padding_mode:oaep \
  -pkeyopt rsa_oaep_md:sha256 \
  -pkeyopt rsa_mgf1_md:sha256

# インポートするファイルを生成する (EncryptedKeyMaterial.bin)
# Combine the encrypted AES key and encrypted key material in a file
cat "$aes_key_wrapped_bin" "$key_material_wrapped_bin" > "$encrypted_key_material_bin"

###
### ステップ 4: キーマテリアルのインポート
### https://docs.aws.amazon.com/ja_jp/kms/latest/developerguide/importing-keys-import-key-material.html
###

aws kms import-key-material \
  --key-id "$key_id" \
  --encrypted-key-material "fileb://$encrypted_key_material_bin" \
  --import-token "fileb://$import_token_bin" \
  --expiration-model KEY_MATERIAL_DOES_NOT_EXPIRE

###
### 公開鍵取得
###
local_pub_key_der64=$(openssl rsa -in "$PRIVATE_KEY_PEM" -outform DER -pubout | openssl enc -e -base64 -A)
kms_pub_key_der64=$(aws kms get-public-key --key-id "$key_id" | jq -r .PublicKey)
if [[ "$local_pub_key_der64" != "$kms_pub_key_der64" ]] ; then
  echo "Public Key not matched!" >&2
  exit 1
fi
kms_pub_key_pem=$(echo "$kms_pub_key_der64" | openssl enc -d -base64 | openssl rsa -pubin -inform der)

cat <<EOF
Key ID: ${key_id}
Public Key:
${kms_pub_key_pem}
EOF

###
### エイリアスの設定
###

if [[ -n "$KMS_ALIAS" ]] ; then
  alias_name="alias/$KMS_ALIAS"
  aws kms create-alias \
    --alias-name "$alias_name" \
    --target-key-id "$key_id"
  echo "Alias Name: ${alias_name}"
fi
```


## JWT の署名に使う

JWT の生成方法は「[GitHub アプリの JSON Web トークン (JWT) の生成](https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app)」で説明されています。
「ヘッダ+ `.` + ペイロード」の文字列を KMS に渡すことで、署名が生成できるはずです。

ヘッダの作成例:
```bash
$ jq -nr '{typ: "JWT", alg: "RS256"} | @base64' | tr '+/' '-_' | tr -d '='
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9
```

ペイロードの作成例:

```bash
$ jq -nr '{iss: 12345, iat: 1732978800, exp: 1732979400} | @base64' | tr '+/' '-_' | tr -d '=' 
eyJpc3MiOjEyMzQ1LCJpYXQiOjE3MzI5Nzg4MDAsImV4cCI6MTczMjk3OTQwMH0
```

CLI だと `aws kms sign` でできます。

- メッセージ部分は base64 エンコードして渡す必要がある。
- 結果は普通の base 64 で返ってくる。
- JWT の署名は Base64 URL エンコードが必要なので `tr` で文字を置換する必要がある。

できた署名は、ローカル環境で同じ秘密鍵で署名したものと一致したので、ちゃんと使えます。

ローカル環境で署名する例:

```bash
$ echo -n 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOjEyMzQ1LCJpYXQiOjE3MzI5Nzg4MDAsImV4cCI6MTczMjk3OTQwMH0' |
  openssl dgst -binary -sha256 -sign /path/to/private-key.pem |
  openssl enc -base64 -A |
  tr '+/' '-_' |
  tr -d '='
```

KMS で署名する例:

```bash
$ aws kms sign \
  --key-id alias/example \
  --message "$(echo -n \
    'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOjEyMzQ1LCJpYXQiOjE3MzI5Nzg4MDAsImV4cCI6MTczMjk3OTQwMH0' |
    base64
  )" \
  --message-type RAW \
  --signing-algorithm RSASSA_PKCS1_V1_5_SHA_256 |
  jq -r .Signature |
  tr '+/' '-_' |
  tr -d '='
```

できた署名の例:

```
C4kDjAeTXsBpnYxBWQA17sBzOFkus6agIw23Ja210i1rRqvAfd3WWMQQIXp5CXiCTjAWaCvOfBaB6McEikYOgUWQSOn67AvQ4BEziUoW2IFBSiWkpPdsmX9y_M9mDSJ-mLXN1J_PAETLeCtDsMnr6tPlsKrM9Km_5t1qtGvo1M4ivnc6X7LXEbVdbrpZYsaNgbmN56XOOz4DG3vEAXBF7FZpwLo0r-mTu1faaUSlGQfvpoP-ddcGPMQ_iZyzZvpCVYxRkX4fcIFOPSQiVKbZ9H8I8cugdaYvNVgLnA2p7nJMz_0LXs9Kxio_Tw21wE5PX-x1Mptrk9J41dldKXce6w
```

これで JWT トークンが作成できました。

アプリをインストールした先のアカウントのリソースにアクセスするには、JWT トークンを使ってさらに[インストールアクセストークンを生成](https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation#generating-an-installation-access-token)する必要があります。

## octokit.js で KMS を使ってみる

octokit.js では秘密鍵を直接使うことを想定していて、[次のようなコードでインストールアクセストークンが自動取得された状態にできます](https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation#using-octokitjs-to-authenticate-with-an-installation-id)。

```js
import { App } from "octokit";

const app = new App({
  appId: APP_ID,
  privateKey: PRIVATE_KEY,
});
const octokit = await app.getInstallationOctokit(INSTALLATION_ID);
```

実際の処理は [@octokit/auth-app](https://github.com/octokit/auth-app.js) が行なっていますが、JWT 署名を外部で行うことは考慮されていないようです。
ただし、代わりに [@octokit/auth-callback](https://github.com/octokit/auth-callback.js) を使って自前実装に置き換えられるようになっています。大変面倒です。

### KMS で署名する処理

こんな感じのコードで署名できます。

```typescript
import { KMSClient, SignCommand } from '@aws-sdk/client-kms';

const KMS_KEY_ID = 'alias/foo';
const kmsClient = new KMSClient();

async function sign(content: string) {
  const command = new SignCommand({
    KeyId: KMS_KEY_ID,
    Message: Buffer.from(content),
    MessageType: 'RAW',
    SigningAlgorithm: 'RSASSA_PKCS1_V1_5_SHA_256',
  });

  const { Signature } = await kmsClient.send(command);
  if (!Signature) {
    throw new Error('Empty signature');
  }
  return Buffer.from(Signature).toString('base64url');
}
```

### インストールアクセストークンを取得する処理

先ほどの署名コードを使うと、こんな感じで実装できます。

```typescript
const GITHUB_API_URL = 'https://api.github.com';

function base64URL(value: string) {
  return Buffer.from(value).toString('base64url');
}

// JWT 生成
async function createJWT({
  clientId,
  iat,
  exp,
}: {
  clientId: string;
  iat: number;
  exp: number;
}) {
  const header = {
    typ: 'JWT',
    alg: 'RS256',
  };
  const payload = {
    iat,
    exp,
    iss: clientId,
  };
  const headerToken = base64URL(JSON.stringify(header));
  const payloadToken = base64URL(JSON.stringify(payload));

  const message = `${headerToken}.${payloadToken}`;
  const signature = await sign(message);

  return `${message}.${signature}`;
}

// GitHub API
async function api(url: string, method: string, jwt: string) {
  const response = await fetch(url, {
    method,
    headers: {
      Accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
      Authorization: `Bearer ${jwt}`,
    },
  });
  if (!response.ok) {
    throw new Error(`Error: ${response.statusText}`);
  }
  return response.json();
}

// GitHub App の Installation ID を取得する
async function fetchInstallationID({
  jwt,
  owner,
  repo,
}: {
  jwt: string;
  owner: string;
  repo: string;
}): Promise<string> {
  const url = `${GITHUB_API_URL}/repos/${owner}/${repo}/installation`;
  const { id } = await api(url, 'GET', jwt);
  if (!id) {
    throw new Error(`No installation ID`);
  }
  return id;
}

// アクセストークンを取得する
async function fetchAccessToken({
  jwt,
  installationID,
}: {
  jwt: string;
  installationID: string;
}): Promise<[string, number]> {
  const url = `${GITHUB_API_URL}/app/installations/${installationID}/access_tokens`;
  const { token, expires_at } = await api(url, 'POST', jwt);
  if (!token) {
    throw new Error(`No installation token`);
  }
  const tokenExpires = Math.floor(Date.parse(expires_at) / 1000) - 60;
  return [token, tokenExpires];
}
```

### Octokit に自前認証処理を組み込む

@octokit/auth-callback を使うには、アクセストークンを返す関数を用意する必要があります。
雰囲気としては、こんな実装で動きます。

```typescript
import { Octokit } from 'octokit';
import { createCallbackAuth } from '@octokit/auth-callback';

const clientId = '12345';
const owner = 'sample-organization';
const repo = 'sample-repository';

// アクセストークンを返す関数
const callback = async () => {
  const now = Math.floor(Date.now() / 1000);
  const iat = now - 60;
  const exp = now + 600;
  const jwt = await createJWT({
    clientId,
    iat,
    exp,
  });
  const installationID = await fetchInstallationID({ jwt, owner, repo });
  const [token, tokenExpires] = await fetchAccessToken({ jwt, installationID });
  return token;
}

const octokit = new Octokit({
  authStrategy: createCallbackAuth,
  auth: {
    callback,
  },
});
```

ただし、実用にするには、取得した JWT, Installation ID, アクセストークンをキャッシュして、期限が切れたら再取得する処理が必要になります。
さらに面倒ですね。Octokit 側で署名部分だけカスタマイズできるようになっていると楽なのですが……

## まとめ

仕組みとしては KMS で署名した JWT トークンを使って GitHub App の認証が通ることを確認できました。ただしアプリケーションで実装するのに octokit.js の対応が不十分なため、大変に骨が折れる作業を強いられます。