株式会社ゆめみ所属メンバの SSH 鍵強度調査

この記事は [YUMEMI Advent Calendar 2021](https://qiita.com/advent-calendar/2021/yumemi) 9 日目の記事です。

_──── SSH鍵強度は意識の高さ_

ということで、[弊社の GitHub Organization](https://github.com/yumemi) に所属しているメンバを対象に SSH 鍵の強度を調べてみました。

## 元ネタ

https://hnw.hatenablog.com/entry/20140705

## 調査結果
### 調査方法

1. [GitHub API](https://docs.github.com/en/rest/reference/orgs#list-organization-members) で Organization に所属するメンバ一覧を取得
  - 非 Public な メンバについても取得する場合は、 `read:org` な scope の Personal access token が必要
1. メンバのIDを元に、登録されている公開鍵を取得
  - `https://github.com/${メンバーのID}.keys` or [GitHub API](https://docs.github.com/en/rest/reference/users#git-ssh-keys) で取得可能
  - 1 人で複数個登録可能なため、登録されている公開鍵すべてを対象とする
1. 公開鍵のアルゴリズム、鍵長を確認

といった手順で調査を実施しました。

### 結果

|鍵種別|個数|割合 [%]|
|:-|:-|:-|
|DSA 1024bit|1|0.3|
|ECDSA 521bit|1|0.3|
|ECDSA-SK 256bit|2|0.6|
|ED25519 256bit|30|8.7|
|RSA 1024bit|4|1.2|
|RSA 2048bit|119|34.6|
|RSA 3072bit|77|22.4|
|RSA 4096bit|109|31.7|
|Unknown|1|0.3|

**合計：344個**

_※ Unknown については、`ssh-keygen` に渡した際にエラーとなったもの_

### 感想

少数とはいえ、 DSA 1024bit と RSA 1024bit 鍵が存在してしまったのが、うーんといった感じです。

逆に ED25519 や ECDSA 521bit 、 ECDSA-SK 256bit といったあたりの利用は光るところです。

特に ECDSA-SK 鍵については、今回の調査で初めて知ったのですが、 [Yubikey 等の Security Key デバイスを利用したGitHub利用](https://github.blog/2021-05-10-security-keys-supported-ssh-git-operations/)を目的としたもののようで、意識の高さを感じます。

全体的には RSA 鍵が多いのですが、その中でも 4096bit 指定が多めなので、 `ssh-keygen` 等で鍵ペアを作成する際にデフォルト設定で作るのではなく、きちんと強度を意識して作られている雰囲気はありました。

## 調査用の CLI ツール

調査手順自体は上述の通りなのですが、手動で実施するのもアレだったので、今回は Swift を利用して CLI ツールを作成、調査を実施しました。

以下、その CLI ツールの作成に関して簡単に記述していこうかと思います。

コード全体は以下のリポジトリに上げています。

https://github.com/hugehoge/gho-ssh-keys-checker

### GitHub API

必要となってくるのは、以下 2 本の REST API です。

- [List organization members](https://docs.github.com/en/rest/reference/orgs#list-organization-members)
- [List public SSH keys for the authenticated user](https://docs.github.com/en/rest/reference/users#git-ssh-keys)

両方とも最大 100 件までしか取得できず、それ以上の全件取得には複数回の API 呼び出しを伴うページング処理が必要となってきます。

また、 SSH 鍵取得のための API はユーザ毎に呼び出す必要があるため、並列処理できないと処理時間的に辛くなる部分となってきます。

### Swift Concurrency

上述の API 呼び出しでの問題点を解消するため、 Swift Concurrency (async/await) を利用した実装を行なっています。

ページング処理については、

```swift:ページング処理
func fetchUserNames(organization: String) async throws -> [String] {
  var userNames: [String] = []

  var loadMore = true
  var page = 1
  while loadMore {
    let request = makeMembersRequest(organization: organization, page: page, perPage: 100)

    guard let (data, _) = try? await URLSession.shared.data(for: request),
          let json = try? JSONSerialization.jsonObject(with: data, options: []),
          let members = json as? [[String: Any]] else {
            throw ToolError.apiCall
          }

    userNames += members.compactMap { $0["login"] as? String }

    loadMore = members.count == 100
    page += 1
  }

  return userNames
}
```

のように、 `while` + `await` でページングしながらの API の直列呼び出しを記述。

API の並列呼び出しについては、

```swift:API&#x20;の並列呼び出し
let userNames = try await client.fetchUserNames(organization: organization)
let userPemKeys: [UserPemKey] =
  try await withThrowingTaskGroup(of: (userName: String, keys: [String]).self) { group in
    for userName in userNames {
      group.addTask {
        try await (userName, client.fetchUserKeys(userName: userName))
      }
    }

    return try await group.reduce(into: [UserPemKey]()) { acc, cur in
      acc += cur.keys.map { UserPemKey(userName: cur.userName, pemKeyString: $0) }
    }
  }
```

のように、`TaskGroup` を利用しての並列呼び出しの待ち合わせを実現しています。(エラー周りにナイーブな実装です)

### 公開鍵種別

Swift の [Security Framework](https://developer.apple.com/documentation/security) 利用で、公開鍵種別の判断もできそうだったのですが、 PEM (テキスト) 形式のフォーマットだと扱いが難しそうだったため、 `ssh-keygen` コマンドを呼び出す形式を採用しました。

シェル形式で記述すると、以下のような処理を Swift 側から呼び出しています。

```sh
$ echo '${公開鍵}' | ssh-keygen -lf - | sed -E 's/^([0-9]+).+\((.+)\)$/\2 \1bit/'
```

- 正規表現での抽出処理も任せるのか
- この部分も Swift Concurrency で並列化できそう

等の問題は残っているのですが、とりあえずはこの処理を公開鍵リストにそのまま適用することで、欲しいフォーマットの公開鍵種別情報を取得しています。

### まとめ

SSH 鍵強度の調査というお題で Swift Concurrency の素振りといった感じだったのですが、まあまあ書き心地はよかったです。

- macOS ターゲットで動作させる場合に、 macOS Monterey (12.0) 以上が必要
- top-level await ができないので、エントリポイントに関して `main.swift` ではなく、 `@main` ディレクティブを持つ別名ファイルを用意したほうがよさそう

といった地味な嵌まりどころはあったのですが、なんとかアドベントカレンダーには間に合いました。