Simple minds think alike

より多くの可能性を

OpenSCを使ってマイナンバーカードで電子署名・検証する方法

マイナンバーカードを使って電子署名できるアプリを作ってみたくて、マイナンバーの仕様に関して調べてまとめてみました。また、実際にOpenSCというツールを使って電子署名・検証し、マイナンバーカードの内部構造の理解を深めました。

前提

  • macOS: 12.4
  • OpenSSL: LibreSSL 2.8.3
  • OpenSC: 0.22.0-rc1-74

マイナンバーカードのインタフェース

まずは、マイナンバーカードの外部からアクセスするためのインタフェースとその規格を確認し、接続に使った機器に関して説明します。

規格

マイナンバーカードは接触・非接触両方のインタフェースを持ち、それぞれ以下の規格に準拠しています。

  • 接触インターフェース
  • 接触インターフェース(NFC)
    • ISO/IEC 14443 TypeB

接触インターフェースであるNFCの代表的の規格は以下の3つであり、マイナンバーカードは2番目のType Bを採用しています。

分類 規格 特徴 使用例
Type-A ISO/IEC 14443 Type A (MIFARE) 比較的安価で低機能 taspo
Type-B ISO/IEC 14443 Type B 高セキュリティ、高処理速度 パスポート、住民基本台帳カード、免許証
Type-F NFC-F ‐ JIS X 6319-4 (FeliCa) 高セキュリティ、高処理速度 SuicaEdy、iD

使用機器・環境

今回の検証では、接触インターフェース(ISO 7816 準拠) ICカードに対応した以下のカードリーダーを使用し、電子署名・検証してみました。

PCからマイナンバーカードの裏面にあるICチップにアクセスできれば接触/非接触どちらのインタフェースでもよかったのですが、2,000円台という安価で壊れても買い直しやすそうだったのでこちらのICカードリーダーで試してみました。

Amazon.co.jp: SCM ICカードリーダー/ライター B-CAS・住基カード対応 SCR3310/v2.0 : パソコン・周辺機器

マイナンバーカードの内部構造

アプリ

総務省のマイナンナーカードの説明によるとマイナンバーカードのICチップには、券面AP・公的個人認証AP(JPKI-AP)・券面入力補助AP・住基APという4つのアプリケーションがあり、以下の概要図のようになっています。

https://www.soumu.go.jp/main_content/000379924.jpg

本記事の検証では、OpenSCというツールを使って電子署名をしたいので公的個人認証AP(JPKI-AP)の部分を使っていきます。

データ構造

また、ICチップ内部のデータ構造は公開されていませんが、ICチップの内部にアクセスして確認すると以下のようなデータ構造になっていることがわかります。

マイナンバーカードのデータ構造

本記事では署名用のデータを使います。

電子署名・検証

いよいよOpenSCとマイナンバーカードにある署名証明書を使って電子署名を行なっていきます。

OpenSCとは

OpenSCは、クロスプラットフォームで動作するスマートカード用のライブラリ・ユーティリティ郡です。主に暗号操作をサポートするカードに対して、認証、メール暗号化、デジタル署名などをするアプリケーションを実装しやすくしてくれるものです。

OpenSCのインストール

以下から環境にあったインストーラーをダウンロードして、OpenSCをインストールします。

GitHub - OpenSC/OpenSC: Open source smart card tools and middleware. PKCS#11/MiniDriver/Tokend

インストール後、pkcs15-crypt, pkcs15-tool の両コマンドが使えるようになっていることを確認します。

$ pkcs15-crypt --version
OpenSC-0.22.0-rc1-74-gc902e199, rev: c902e199, commit-time: 2021-08-10 11:09:03 +0200
$ pkcs15-tool  --version
OpenSC-0.22.0-rc1-74-gc902e199, rev: c902e199, commit-time: 2021-08-10 11:09:03 +0200

これらのツールの用途はそれぞれ以下の通りです。

  • pksc15-creypt
    • ICカードに保存された鍵を用いて、電子署名の計算やデータの復号化などの暗号処理を実行するためのツールです。
  • pkcs15-tool
    • PIN・証明書・公開鍵など、カード内部のデータにアクセスするためのツールです。

電子署名

以下のコマンドで署名対象のファイル( plain.txt )を作成します。(RSA署名できるのは254バイトまでなので、実際には署名対象自体ではなくダイジェストに対して署名します)

$ echo "hello" > plain.txt

次に以下のコマンドで、PKCS#15オブジェクトを全て表示し、署名用の秘密鍵のIDを確認します。

$ pkcs15-tool --dump
〜〜〜 他のPKCS#15オブジェクト 〜〜〜
Private RSA Key [Digital Signature Key]
    Object Flags   : [0x01], private
    Usage          : [0x204], sign, nonRepudiation
    Access Flags   : [0x1D], sensitive, alwaysSensitive, neverExtract, local
    Algo_refs      : 0
    ModLength      : 2048
    Key ref        : 2 (0x02)
    Native         : yes
    Auth ID        : 02
    ID             : 02
    MD:guid        : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
〜〜〜 他のPKCS#15オブジェクト 〜〜〜

署名用の秘密鍵のIDが 2 だということがわかりました。また、コマンドの結果から、マイナンバーカード内には以下の10個のオブジェクトがあることが分かります。

  • PIN [User Authentication PIN] - 認証用PIN
  • PIN [Digital Signature PIN] - 署名用PIN
  • Private RSA Key [User Authentication Key] - 認証用秘密鍵
  • Private RSA Key [Digital Signature Key] - 署名用秘密鍵
  • Public RSA Key [User Authentication Public Key] - 認証用公開鍵
  • Public RSA Key [Digital Signature Public Key] - 署名用公開鍵
  • X.509 Certificate [User Authentication Certificate] - 認証証明書
  • X.509 Certificate [Digital Signature Certificate] - 署名証明書
  • X.509 Certificate [User Authentication Certificate CA] - 認証用CA証明書
  • X.509 Certificate [Digital Signature Certificate CA] - 署名用CA証明書

pkcs15-crypt-k オプションに署名用の秘密鍵のID 2 を指定して、作成したテキストファイルを署名します。また、署名用パスワード(英数字6-16文字)の入力が求められるので入力します。(※英字のアルファベットは大文字を入力する必要があります。)

$ pkcs15-crypt -s -k 2 --pkcs1 -R -i plain.txt -o text.signed

pkcs15-crypt はデフォルトだと入力データが正しい長さでパディングされたものと見なして扱うので --pkcs1 オプションを指定して入力データをパディングしてあげています。(参考)

パスワードを間違えてしまった場合、以下のコマンドでPINがブロックされるまでの残り回数を確認できます。PINがブロックされると役所に行ってリセットを依頼する必要があるのでご注意を。

$ pkcs15-tool --list-pins

署名済みのファイルtext.signedができました。

署名検証

次に署名証明書の公開鍵を使って、署名済みのファイルの署名検証を行います。

以下のコマンドで、署名用のPINと証明書のIDを確認します。それぞれ 2 であることが分かります。

$ pkcs15-tool --dump
PIN [Digital Signature PIN]
    Object Flags   : [0x12], modifiable
    ID             : 02
    Flags          : [0x12], local, initialized
    Length         : min_len:6, max_len:16, stored_len:0
    Pad char       : 0x00
    Reference      : 2 (0x02)
    Type           : ascii-numeric
    Tries left     : 5

X.509 Certificate [Digital Signature Certificate]
    Object Flags   : [0x01], private
    Authority      : no
    Path           : 0001
    ID             : 02
    Encoded serial : 00 00 XXXXXXXXX

pkcs15-tool--read-certificate オプションに署名証明書のID 2--auth-id オプションにPINの auth-id02 指定して、署名証明書を取得します。再度、署名用パスワードの入力が求められるので入力します。

$ pkcs15-tool --read-certificate 2 --verify-pin --auth-id 02
Using reader with a card: SCR3310 Smart Card Reader
Please enter PIN [Digital Signature PIN]:
-----BEGIN CERTIFICATE-----
xxxxx
-----END CERTIFICATE-----

出力された-----BEGIN CERTIFICATE-----から-----END CERTIFICATE-----の箇所をファイル名 sign.crt として保存します。

以下のコマンドで署名証明書(sign.crt)から公開鍵の部分を取り出して、ファイル sign.pubに保存します。

$ openssl x509 -in sign.crt -noout -pubkey > sign.pub

先ほど作成した署名済みファイル(text.signed)を公開鍵(sign.pub)で検証すると内容を確認できます。

$ openssl rsautl -verify -pubin -inkey sign.pub -in text.signed
hello

参考資料

関連記事

simple-minds-think-alike.moritamorie.com

【Golang】GoLandにstaticcheckを設定する方法

Go言語のlinter staticcheck をGoLandでも有効にする方法を調べてみました。 staticcheck.io

プラグインとして提供されていると楽そうですが、2022年6月現在 staticcheckプラグインはないので、IntelliJ IDEA製品の機能であるFile Watchersを使って有効にしています。

前提

  • staticcheck 2022.1.2 (v0.3.2)
  • GoLand 2022.1.3

導入

staticcheckインストール

$ go install honnef.co/go/tools/cmd/staticcheck@latest

インストールしたらバイナリのパスを確認します。

$ which staticcheck
/Users/t.morita/.anyenv/envs/goenv/shims/staticcheck

GoLandの設定

GoLandのPreferences...を選択

ツールからFile Watchersを選択

テンプレートを選択します。 File Watchersの設定画面でテンプレートを選択

プログラムの欄に、先ほど確認したstaticcheckのバイナリのパスを指定します。 File Watchersの設定

最初ファイルタイプ「Goファイル」を見つけられなかったので、あれ!?どうやって設定するんだろうと思ったのですが、デフォルトで設定されているファイルタイプ「不明」の上の方にありました。 [:上にスクロールするとテンプレートからGoファイルが見つかる]

すべてのプロジェクトで staticcheck を有効にするために、レベルをグローバルに変更します。 すべてのプロジェクトで staticcheck を有効にする

確認

設定が終わると、ファイルタイプに該当するファイルを編集すると自動的に staticcheck を実行し、結果を出力してくれます。

以下のようなコードに対して staticcheck を実行して結果を確認してみます。

package main

import (
    "fmt"
    "log"
    "os"
)

func main() {
    f, err := os.Open("hoge.txt")
    fmt.Printf("%s\n", f.Name())

    if err != nil {
        log.Fatal(err)
    }
    f, err = os.Open("hoge.txt")
    fmt.Printf("%s\n", f.Name())
}

初回実行時、実行の出力タブに以下のように表示されていました。 初回実行時の実行の出力タブ表示

「プロジェクトを信頼して実行」を選択すると、staticcheck が実行され、以下のようにエラーになった内容が表示されます。 プロジェクトを信頼して実行を押すとエラー内容が表示される

エラーの内容を読むと err が使われていないことがわかるのでエラーチェックが漏れていることに気がつけそうです。

また、エラーのファイル:行数をクリックすると、エディタの該当行にフォーカスが当たってくれるのも便利です。

[:エラーのファイル:行数をクリックするとエディタの該当行がフォーカスされる]

参考資料

【Golang】Cookieを認証・暗号化するライブラリ gorilla/cookie を試してみた

最近、仕事でCookie使ったGo言語コードを見かけた際に gorilla/securecookie が使われていたので、このライブラリに関してを調べてみました。その時に調べた内容を書きたいと思います。

ginecho といったGo言語でよく使われているWebフレームワークの内部でも gorilla/securecookie を使っているので、gorilla/securecookie の役割/仕組みを理解することで、goのWebアプリケーションのCookie管理の仕組みを把握できると思います。

前提

概要

公式のREADMEを読むと以下のように書いてありました。

securecookie encodes and decodes authenticated and optionally encrypted cookie values.

securecookie は認証され、オプションで暗号化されたCookieの値をエンコードおよびデコードします。

Secure cookies can't be forged, because their values are validated using HMAC. When encrypted, the content is also inaccessible to malicious eyes. It is still recommended that sensitive data not be stored in cookies, and that HTTPS be used to prevent cookie replay attacks.

セキュアCookieは、その値がHMACを使用して認証されるため、偽造することができません。暗号化された場合、コンテンツは悪意のある目からアクセスすることもできません。

securecookie は、標準パッケージの net/httpのcookie にはない認証と暗号化の仕組みを提供してくれるようです。

標準パッケージの挙動を確認

標準パッケージを使ってCookieを設定するだけのシンプルなコードを試してみました。

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/set-cookie", func(w http.ResponseWriter, 
        r *http.Request) {

        cookie := &http.Cookie{
            Name:  "title",
            Value: "SPY x FAMILY",
        }
        http.SetCookie(w, cookie)

        fmt.Fprintf(w, "Cookieをセットしました")
    })

    log.Fatal(http.ListenAndServe(":8080", nil))
}

Webブラウザでパス: /set-cookie、ポート: 8080にアクセスすると以下のようになり、想定したCookieが設定されていることが確認できます。

 標準パッケージ使用時のCookieの値

Cookieはこのようにブラウザツールで簡単に参照、改変できるためセキュリティが低い状態になっています。

securecookieの認証の挙動を確認

securecookieを使ってコードを書き直す

gorilla/securecookie を使ってコードを書き直すと、セキュリティを向上できることを確認します。

securecookie.Newの第1引数にはHMACのハッシュキーを指定します。第2引数には暗号化のためのブロックキーを指定できますが、今回は nil を指定し、暗号化しないようにしてみます。

package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/gorilla/securecookie"
)

func main() {
    hashKey := []byte("hash-key")
    s := securecookie.New(hashKey, nil)

    http.HandleFunc("/set-cookie", func(w http.ResponseWriter,
        r *http.Request) {

        values := map[string]string{
            "title": "SPY x FAMILY",
        }
        encoded, err := s.Encode("cookie-name", values)
        if err == nil {
            cookie := &http.Cookie{
                Name:     "cookie-name",
                Value:    encoded,
            }
            http.SetCookie(w, cookie)

            fmt.Fprintf(w, "Cookieをセットしました")
        }
    })

    log.Fatal(http.ListenAndServe(":8080", nil))
}

同じようにWebブラウザでアクセスするとCookieの値が変わり、そのままでは読めなくなっていることが分かります。

 securecookie使用時のCookieの値

以下のハンドラーを追加し、Cookie内のハッシュ値を検証し、通れば値をデシリアライズしてCookieの値を表示します。

http.HandleFunc("/show-cookie", func(w http.ResponseWriter,
    r *http.Request) {

    if cookie, err := r.Cookie("cookie-name"); err == nil {
        value := make(map[string]string)

        err = s.Decode("cookie-name", cookie.Value, &value)
        if err == nil {
            fmt.Fprintf(w, "値は%qです。", value["title"])
        } else {
            fmt.Fprintf(w, err.Error())
        }
    }
})

WebブラウザでアクセスするとCookieから取得した値が表示されることを確認できます。

Cookieから取得した値を表示

認証の仕組み

上記のコードと同様に、Cookieの名前を"cookie-name"、Cookieのデータ(Name: "title", Value: "SPY x FAMILY")とした場合の認証のフローです。

このフローではオプションである暗号化は考慮していないので機密性が上がるわけではありません。自身のサーバーがハッシュキーを使って保存したものか認証できるようになるだけです。

Cookieの値作成・保存

以下の図のフローでCookieの値を作成、保存します。 Cookieの値作成・保存までのフロー図

ハッシュ値を算出

基本文字列を"cookie-name|【タイムスタンプ】|Cookieデータ"とし、ハッシュキーと合わせてHMACハッシュ値(mac)を算出します。

Cookieに設定する値を作り、保存

次に "【タイムスタンプ】|Cookieデータ|ハッシュ値(mac)"をbase64エンコードし、Cookieとして保存します。

Cookieの取得・検証

以下の図のフローでCookieの値を取得・検証します。

Cookieの取得・検証までのフロー図

ハッシュ値を取り出し

Cookieから取り出した"【タイムスタンプ】|Cookieデータ|ハッシュ値(mac)"をbase64デコードし、ハッシュ値(mac)を取り出します。

Cookieの検証

次に、"cookie-name"を追加して"cookie-name|タイムスタンプ|Cookieデータ"というデータを作り、ハッシュキーと合わせてHMACでハッシュ値(mac)を算出し、取り出したハッシュ値と値が一致するかで検証できます。

Cookieの内容を改変し、わざと検証失敗させてみる

開発者ツールを使ってCookieの値を改変してみます。

Cookieの値の検証

再度Webブラウザ/show-cookie にアクセスすると、検証エラーになるかことを確認できます。

Cookieの値の検証で失敗した状態

securecookieの暗号化の挙動を確認

securecookieを使ってコードを書き直し、暗号化してみる

securecookie.Newの第2引数には暗号化のためのブロックキーを指定できます。

hashKey := []byte("hash-key")
blockKey := []byte("blocoooooock-key")
s := securecookie.New(hashKey, blockKey)

ぱっと見では、認証だけの場合と何も変わっていないように見えます。 securecookieを使った場合のCookieの値

暗号化/複合化の仕組み

オプションである暗号化を有効にすることで機密性が上がります。

以下の図のように認証処理の前に、Cookieのデータに対してAES(CTRモード)で暗号化を行うことで、Webブラウザには平文が保存されないようになります。

 securecookie使用時の暗号化/複合化の仕組み

複合時は、Cookieの署名検証が問題なければ暗号化されたCookieデータをブロックキーで複合化します。

参考記事

【Golang】slack-goを使ってSlackのリクエスト署名を検証する<解説篇>

以下の記事ではGo言語のライブラリ slack-go を使ってSlackのリクエスト署名検証を行うサンプルアプリを作成しましたが、今回はその検証処理の詳細を確認していきたいと思います。

simple-minds-think-alike.moritamorie.com

なお、Slackのリクエスト署名に関しては以下の公式記事を参考にしました。

前提

  • go: v1.17
  • slack-go: v0.10.3

リクエスト署名の概要

公式の記事によると

リクエスト署名は、Slack がアプリにデータを送信する API メソッドでサポートされています。これには、スラッシュコマンド、Events API リクエスト、インタラクティブ・メッセージ、アクション、メッセージボタン、メッセージメニュー、レガシー Outgoing Webhook が含まれます。

ということで、基本的にSlackからアプリに送信されるHTTPリクエストはリクエスト署名検証できるようになっているようです。

リクエスト署名検証の仕方(理論)

また、リクエスト署名検証には以下の3つが必要なようです。

  • X-Slack-Signature HTTP ヘッダー
  • アプリ固有のシークレットキー( Signing Secret )
  • 基本文字列
    • バージョン番号 (現在は常に v0)
    • X-Slack-Request-Timestamp HTTP ヘッダーの値
    • リクエスト自体のメッセージ本文

X-Slack-Signature HTTP ヘッダー

公式記事に以下のように記載されていました。

このヘッダーには、HMAC-SHA256 キー付きハッシュが設定されています。このハッシュは、アプリ固有のシークレットをキーとして使って計算されます。

具体的にはv0=c11b919c9ea488395441d317....のような値になります。基本文字列とアプリ固有のシークレットでSHA256 ハッシュ計算されたものです。アプリ側でも同じ計算を行い、ハッシュが同じ値になることを確認することで署名検証できます。

アプリ固有のシークレットキー( Signing Secret )

xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxのような値がアプリ毎に発行され、Slack APIOAuth & Permissionsから取得できます。

基本文字列

バージョン番号 (現在は常に v0)、X-Slack-Request-Timestamp HTTP ヘッダーの値、リクエスト自体のメッセージ本文の順で、コロン (:) で区切って連結し、基本文字列を作成します。

例えば、基本文字列は次のようになります v0:1538352000:token=abc123def456xyz....

リクエスト署名検証の仕方(実装)

上記の検証を行なっている アプリとslack-go の実装を確認していきます。

アプリ側の実装

作成したサンプルアプリmiddleware.goから署名検証を行なっている箇所を抜粋し、処理内容をコメントで追記しています。

以下の①〜③の番号は次の slack-go の実装と関連する箇所を明示しています。

// ①リクエスト署名検証に必要な情報(基本文字列のメッセージ本文以外)を設定し、
//    シークレット検証機を生成
sv, err := slack.NewSecretsVerifier(r.Header,
    os.Getenv("SLACK_SIGNING_SECRET"))
if err != nil {
    w.WriteHeader(http.StatusBadRequest)
    return
}

// ②シークレット検証機に不足情報(基本情報の文字列のメッセージ本文)を追加
if _, err := sv.Write(body); err != nil {
    w.WriteHeader(http.StatusInternalServerError)
    return
}

// ③SHA256 ハッシュ計算し、ハッシュが同じ値になることを確認
if err := sv.Ensure(); err != nil {
    w.WriteHeader(http.StatusUnauthorized)
    return
}

slack-goの実装

署名検証は、security.goの実装に含まれており、そこからの抜粋になります。

①リクエスト署名検証に必要な情報(基本文字列のメッセージ本文以外)を設定し、シークレット検証機を生成

シークレット検証機生成とタイムスタンプのチェックをしています。

func NewSecretsVerifier(header http.Header,
    secret string) (sv SecretsVerifier, err error) {
    var (
        timestamp int64
    )

    stimestamp := header.Get(hTimestamp)

    // シークレット検証機生成
    if sv, err = unsafeSignatureVerifier(header, secret); err != nil {
        return SecretsVerifier{}, err
    }

    // タイムスタンプを10進数(64ビット長)に変換
    if timestamp, err = strconv.ParseInt(stimestamp, 10, 64); err != nil {
        return SecretsVerifier{}, err
    }

    // リクエストヘッダーのタイムスタンプ(X-Slack-Request-Timestamp HTTP ヘッダーの値)と現在時刻を比較し、
    // 5分より長ければエラーにする
    diff := absDuration(time.Since(time.Unix(timestamp, 0)))
    if diff > 5*time.Minute {
        return SecretsVerifier{}, ErrExpiredTimestamp
    }

    return sv, err
}

NewSecretsVerifier の中から呼ばれている unsafeSignatureVerifier の実態は以下の通りです。

const (
    hSignature = "X-Slack-Signature"
    hTimestamp = "X-Slack-Request-Timestamp"
)

func unsafeSignatureVerifier(header http.Header, 
    secret string) (_ SecretsVerifier, err error) {
    var (
        bsignature []byte
    )

    // HTTPヘッダーから署名とリクエストタイムスタンプを取得
    signature := header.Get(hSignature)
    stimestamp := header.Get(hTimestamp)

    if signature == "" || stimestamp == "" {
        return SecretsVerifier{}, ErrMissingHeaders
    }

    // 署名をデコード
    if bsignature, err = hex.DecodeString(
        strings.TrimPrefix(signature, "v0=")); err != nil {
        return SecretsVerifier{}, err
    }

    // シークレットをキーとしてHMACハッシュを生成する
    hash := hmac.New(sha256.New, []byte(secret))

    // ハッシュに"v0:【リクエストタイムスタンプ】"を書き込む
    if _, err = hash.Write(
        []byte(fmt.Sprintf("v0:%s:", stimestamp))); err != nil {
        return SecretsVerifier{}, err
    }

    return SecretsVerifier{
        signature: bsignature,
        hmac:      hash,
    }, nil
}

http.Headerとsecretを引数にとり、検証機を生成して返します。なお、ここで使用している HMAC-SHA-256というアルゴリズムに関しては、別の記事で概要を記載していますので、もしよろしければ参考にしてみてください。

simple-minds-think-alike.moritamorie.com

②シークレット検証機に不足情報(基本情報の文字列のメッセージ本文)を追加

func (v *SecretsVerifier) Write(body []byte) (n int, err error) {
    return v.hmac.Write(body)
}

③SHA256 ハッシュ計算し、ハッシュが同じ値になることを確認

func (v SecretsVerifier) Ensure() error {
    // ハッシュ値を算出
    computed := v.hmac.Sum(nil)

    // 署名と算出したハッシュ値が同じになることを確認
    if hmac.Equal(computed, v.signature) {
        return nil
    }

    if v.d != nil && v.d.Debug() {
        v.d.Debugln(fmt.Sprintf("Expected signing signature: %s, but computed: %s", hex.EncodeToString(v.signature), hex.EncodeToString(computed)))
    }
    return fmt.Errorf("Computed unexpected signature of: %s", hex.EncodeToString(computed))
}

Best Plactice for security

他のSlack APIに関するセキュリティの対応は Best Plactice for security という公式の記事にまとめられています。

api.slack.com

2022年6月時点での目次を転記しておきます。

関連記事

simple-minds-think-alike.moritamorie.com

simple-minds-think-alike.moritamorie.com

【Golang】slack-goを使ってSlackのリクエスト署名を検証する<実装編>

Slack連携アプリのセキュリティを高めたくて、リクエストの署名検証に関して調べてみました。また、調べた内容を元にサンプルアプリを作ってみました。

モチベーション

コロナを機にリモートワークが普及してから、仕事でのコミュニケーションにSlackを使うようになり、便利なSlack連携アプリの使用頻度が上がっています。ただ、Slack連携アプリが公開しているリクエストURLはどこからでもアクセスできるため、リスク対策を十分にする必要性を感じています。

何も対策をしなければ第三者からの攻撃に対して無防備になってしまうため、Slackのリクエスト検証の仕組みに関して正しく知っておきたいというのがモチベーションです。

全体像

この記事では、SlackからのHTTPリクエストを署名検証する処理を含む以下のアプリを実装していきます。

Slackメッセージのショートカットを実行すると、Slackから送られてくるペイロードを受け取り、署名を検証し、問題がなければSlackチャネルにメッセージ投稿する、というシンプルなアプリです。 SlackからのHTTPリクエストを署名検証する処理を含むアプリ

参考までに、SlackからのHTTPリクエストに含まれるペイロードは以下参照先のデータ形式になります。
Making messages interactive | Slack

Slackでのアプリ設定

Slackチャネルにショートカットを追加し、アプリと連携できるように設定を行なっていきます。

ショートカット設定

Slack APIからアプリを作成

アプリが作成できたら左側メニューの「Interactivity & Shortcuts」を選択し、下図中の①〜③をそれぞれ設定します。

  • ①InteractivityをOnにする
  • ②Request URLを入力
  • ③「Create New Shortcut」を押す

Slackのショートカット作成画面までの手順

ショートカットの設定ダイアログで「On message」を選択し、メッセージショートカットを設定していきます。 Slackのショートカット作成画面

ショートカットの名前、説明、Callback IDにそれぞれ任意の値を設定します。 [n:alt=Slackのショートカット作成-詳細画面]

スコープ追加

画面左メニューの「OAuth & Permissions」を押し、Bot Token Scopesに「chat:write」を追加し、連携アプリの処理結果をSlackチャネルに通知できるようにします。 Slackのスコープ追加画面

ワークスペースとチャネルにアプリを追加

「OAuth & Permissions」の「Install to workspace」を押します。

以下2点を控えたら、チャネルのIntegrationの設定から作成したアプリを追加します。

  • Basic Information - App Credentials - Signing Secret
    • 署名を検証するためのシークレット

Signing Secretの取得

  • OAuth & Permissions - OAuth Tokens for Your Workspace - Bot User OAuth Token
    • Slackチャネルにメッセージを投稿するためのOAuthトーク

Bot User OAuth Tokenの取得

試しに追加したショートカットを実行してみる

Slackチャネルの任意のメッセージに対して先ほど追加した「Verify Request」というショートカットを実行できます。

Slack連携アプリの実行

ただ、今はサーバー側のGoアプリが動いていないので、HTTPリクエストが処理されず、エラーが発生します。

アプリの実装(Goのコード)

コードはmain.goとmiddleware.goの2ファイルに分けて記載しています。

これらはそれぞれ以下の役割を担っています。

  • main.go
    • HTTP80番ポートを待ち受ける
    • Slackからパス/verifing-requestにHTTPリクエストがあれば、handler関数(handleInteraction)を実行する
    • ショートカット独自の処理を行うhandler関数(handleInteraction)を定義
  • middleware.go
    • handlerの処理に介入して、署名検証する

使用ライブラリ

slack-goというライブラリを使用して実装しています。

github.com

また、今回作成したサンプルアプリは slack-go の以下のexampleコードを参考にして作りました。署名検証部分をmiddleware共通処理として切り出していて、保守しやすい綺麗な実装になっていて、参考にするのに良いサンプルのように感じています。

main.go

ショートカット独自の処理を行う handler(handleInteraction) 関数では、SlackからのHTTPリクエストからペイロードを取り出し、メッセージアクションというInteractionTypeであればチャネルに「メッセージアクションが実行されました!」というメッセージ投稿を行っています。

先ほど控えた Bot User OAuth Token環境変数 SLACK_BOT_OAUTH_TOKEN に設定しています。Slack APIクライアント初期化の際に、この環境変数を使用しています。

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"

    "github.com/slack-go/slack"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/verifing-requests", handleInteraction)

    middleware := NewSecretsVerifierMiddleware(mux)
    log.Fatal(http.ListenAndServe(":80", middleware))
}

func handleInteraction(w http.ResponseWriter, r *http.Request) {
    fmt.Println("[START]handleInteraction")

    api := slack.New(os.Getenv("SLACK_BOT_OAUTH_TOKEN"))

    var payload *slack.InteractionCallback
    err := json.Unmarshal([]byte(r.FormValue("payload")), &payload)
    if err != nil {
        log.Println(err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    switch payload.Type {
    case slack.InteractionTypeMessageAction:
        api.PostMessage(payload.Channel.ID,
            slack.MsgOptionText("メッセージアクションが実行されました!", false))
    }

    fmt.Println("[END]handleInteraction")
}

middleware.go

HTTPリクエストのボディ・ヘッダーを読み込み、環境変数 SLACK_SIGNING_SECRET を元に署名検証しています。署名検証の具体的な内容は次の<解説篇>で共有したいと思います。

環境変数 SLACK_SIGNING_SECRET には先ほど控えたSigning Secretを設定します。

package main

import (
    "bytes"
    "fmt"
    "io/ioutil"
    "net/http"
    "os"

    "github.com/slack-go/slack"
)

type SecretsVerifierMiddleware struct {
    handler http.Handler
}

func NewSecretsVerifierMiddleware(
    h http.Handler) *SecretsVerifierMiddleware {
    return &SecretsVerifierMiddleware{h}
}

func (v *SecretsVerifierMiddleware) ServeHTTP(
    w http.ResponseWriter,
    r *http.Request) {

    fmt.Println("[Start]ServeHTTP")

    body, err := ioutil.ReadAll(r.Body)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    r.Body.Close()
    r.Body = ioutil.NopCloser(bytes.NewBuffer(body))

    sv, err := slack.NewSecretsVerifier(r.Header,
        os.Getenv("SLACK_SIGNING_SECRET"))
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    if _, err := sv.Write(body); err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    if err := sv.Ensure(); err != nil {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }

    fmt.Println("before hander.ServeHTTP")

    v.handler.ServeHTTP(w, r)

    fmt.Println("[END]ServeHTTP")
}

再度Slack上からショートカットを実行してみる

Goコードのアプリをサーバー上にデプロイして、SlackからのHTTPリクエストを処理できるようになったところで、再度「Verify Request」ショートカットを実行してみます。

再度Slack連携アプリを実行

メッセージ投稿が表示され、期待した結果になっていることが分かります。

コンソール出力は以下のようになりました。

[Start]ServeHTTP
before hander.ServeHTTP
[START]handleInteraction
[END]handleInteraction
[END]ServeHTTP

SecretsVerifierMiddlewareServeHTTP メソッドが実行され、SlackからのHTTPリクエストの署名検証が行われた後、ショートカット独自のhandlerが動いていることを確認できます。

次の記事では、署名検証の内容を読み解いてみます。

simple-minds-think-alike.moritamorie.com

参考コード

コードの方はGithubにも挙げてみたのでもしよろしければ参考にしてみてください。 github.com

関連記事

simple-minds-think-alike.moritamorie.com

参考資料

Slack APIを通じてメッセージ投稿する方法

Go言語のコードからSlack APIアクセスを試してみたくて、Slackの設定方法を調べてみました。今回はGolangのコードからではなくcurlを叩いてSlackメッセージ投稿できるところまでの手順を記載してみようと思います。

今回curlからアクセスするのはchat.postMessageというメッセージ投稿用のAPIメソッドです。

参考にしたドキュメント

公式Slack API 日本語版ページの「チュートリアル - Bolt フレームワークを使って Slack Bot を作ろう」と「権限 (スコープ) - ボットの OAuth スコープについて」を参考にしてみました。

これらのドキュメントは少し古く、現状のSlackのUIと異なっているところがあるため2022年6月時点のUIを元に記事を書きます。

アプリを作成し、ワークスペースにインストールする

Slack API: https://api.slack.com/appsにアクセスします。

「Create an App」を押します。

表示されるダイアログで「From scrach」を押します。これはSlackのUI上からアプリの設定を行う方法です。下の「From an app manifest」はマニフェストファイルを使ってアプリの設定を行う方法で、同じような設定を複数回行う場合にはこちらの方法が早いです。

アプリの名前とアプリをインストールするワークスペースを選択し、「Create App」を押します。

作成したアプリにOAuthスコープの追加

アプリがSlack APIにアクセスするためには、アプリで使用できるユーザートークンまたはボットトークンに対して適切なOAuthスコープを追加する必要があります。

ユーザートークンとボットトーク

トークンは、ユーザートークンとボットトークンに分かれます。Bolt入門ガイドに記載されている説明を引用します。

・ユーザートークン を使用すると、アプリをインストールまたは認証したユーザーに成り代わって API メソッドを呼び出せます。1 つのワークスペースに複数のユーザートークンが存在する可能性があります。
・ボットトークン はボットユーザーに関連づけられ、1 つのワークスペースでは最初に誰かがそのアプリをインストールした際に一度だけ発行されます。どのユーザーがインストールを実行しても、アプリが使用するボットトークンは同じになります。ほとんどのアプリで使用されるのは、ボットトークンです。

認証したユーザーに成り代わってメッセージ投稿するのではなく、ボットとしてメッセージ投稿させたいのでボットトークンの方にOAuthスコープを設定します。

スコープの概要と適切なスコープの探し方

ボットの OAuth スコープについて、という記事に記載されています。

今回curlからアクセスする chat.postMessage というAPIメソッドのドキュメント(下記リンク)を参照すると、Required scopesに「chat:write」が指定されているため、ボットトークンにこのOAuthスコープを追加します。
https://api.slack.com/methods/chat.postMessage

設定

左側メニューのFeaturesカテゴリにある「OAuth & Permissions」を選択。

ScopesのBot Token Scopesに、チャットへの書き込み権限「chat:write」を追加します。

「Install to Workspace」を押し、選択したワークスペースに対して作成したアプリをインストールします。

画面上部にBot User OAuth Tokenが表示されます。

Slackチャネルにアプリをインストールする

次にSlackワークスペース画面右上のアイコンを押し、チャネルの設定ダイアログを開きます。「インテグレーション」タブにある「アプリを追加する」ボタンを押します。

先ほど作成した「API TEST」アプリを追加します。

無事追加されると参加者としてメッセージが表示されます。

curlでSlackチャネルにメッセージを投稿する

発行したBot User OAuth Token、連携したチャネル名、投稿したいメッセージを指定してPOSTを送ります。これらの引数はAPIメソッドのドキュメントで必須(Required)として指定されている項目です。

$ curl -X POST 'https://slack.com/api/chat.postMessage' \
       -d 'token=your_bot_user_oauth_token' \
       -d 'channel=#general' \
       -d 'text=テスト'

投稿したメッセージが表示されます。

関連記事

simple-minds-think-alike.moritamorie.com

GitLab CI/CDのGitLab Runnerでジョブを動かす方法

仕事のソースコード管理にGitLabを使っていて、CI/CDに関してもGitLab CI/CDを使っているのですが、いまいち仕組みがよく分からなかったので学習のために以下のQuick Startチュートリアルをやってみました。その時の手順を書いてみます。

また、普段はセルフホストのGitLabを使っていますが、今回は手頃なGitlab.com環境で試してみます。

docs.gitlab.com

事前に行ったこと

作成したGitLabリポジトリ

以下の手順で作成したCI/CDプロジェクトを作ってみました。もし、よろしければ実際にどのように結果が出力されるか等確認してみてください。 gitlab.com

CI/CDプロセスの概要

GitLab CI/CDを使うために以下を行いました。

  • CI/CDジョブを動かすGitLab Runnerが利用可能か確認
  • リポジトリのルートに .gitlab-ci.yml(ジョブを定義するファイル) 作成

CI/CDジョブを動かすGitLab Runnerが利用可能か確認

GitLab公式のドキュメントによると、GitLab RunnerとはGitLab CI/CDと連携し、パイプラインでジョブを実行するためのアプリケーションとのことです。

GitLab.comのリポジトリを開き、[設定]=>[CI/CD]の Runnerの箇所で稼働中のRunnerを確認できます。

GitLab Runnerには以下の2種類のRunnerがあるようで、GitLabでリポジトリを作成した時点で最初からShared runnersは利用可能なようでした。

  • Shared runners (複数プロジェクトのジョブを共有のRunnerで処理する方式)
  • Specific runners (特定のプロジェクトのジョブのみ実行する方式)

Shared runnersでCI/CDジョブを処理する分には、gitlab.comでRunnerに関する事前作業は必要なさそうです。次に.gitlab-ci.yml(ジョブを定義するファイル)を作成していきます。

リポジトリのルートに.gitlab-ci.yml 作成

.gitlab-ci.yml はGitLab CI/CDの具体的な指示を設定するYAMLファイルとのことです。

リポジトリのルートに以下内容の .gitlab-ci.yml を作成します。作成方法は、GitLabのUI上で作成する、またはローカルでファイルを作成してgit pushするでもどちらでもOK。

build-job:
  stage: build
  script:
    - echo "Hello, $GITLAB_USER_LOGIN!"

test-job1:
  stage: test
  script:
    - echo "このジョブは何かをテストする"

test-job2:
  stage: test
  script:
    - echo "このジョブは何かをテストしますが、test-job1より時間がかかります"
    - echo "echoコマンドの後、20秒のsleepコマンドを実行します"
    - echo "これはtest-job1より20秒長いテスト実行をシミュレートしてます"
    - sleep 20

deploy-prod:
  stage: deploy
  script:
    - echo "このジョブは $CI_COMMIT_BRANCH ブランチから何かをデプロイします"

commitが作られると自動的にパイプラインが作られます。

パイプラインの確認

CI/CD > パイプラインを選択すると実行されているパイプラインを表示できます。

ステータスをクリックすると、パイプラインの詳細情報を表示できます。

stageがbuild, test, deployの順でジョブが実行され、全てのジョブが成功するとパイプラインのステータスも「成功」に変わります。

.gitlab-ci.yml tips

ここまででQuick Startは終わりですが、記事中の.gitlab-ci.yml tipsという項目を読んでおくと、色々なCI/CDを実装する上でのベースとなる知識が得られて良さそうでした。内容を記載しておきます。

  • .gitlab-ci.ymlファイルを作成したら、それ以降の編集はすべてパイプラインエディタで行ってください。パイプラインエディタを使用すると、次のことができます。
    • パイプラインの設定を、自動的な構文強調表示と検証を使用して編集する。
    • CI/CD設定の視覚化、.gitlab-ci.ymlファイルのグラフィック表示の表示。
  • ランナーがDockerコンテナを使ってジョブを実行する場合は、.gitlab-ci.ymlファイルを編集してイメージ名を記述してください。
    • default:
        image: ruby:2.7.4
      
  • 各ジョブにはスクリプトとステージが含まれます。
    • default キーワードは、before_scriptafter_script などのデフォルトの設定をカスタムするためのものです。
    • ステージは、ジョブの順次実行を記述します。1つのステージのジョブは、利用可能なランナーがある限り、並行して実行されます。
    • ステージの順序から外れてジョブを実行するには、Directed Acyclic Graphs (DAG)キーワードを使用します。
  • ジョブやステージの実行方法をカスタマイズするために、追加で設定できます。
    • rules キーワードを使用して、ジョブを実行またはスキップするタイミングを指定します。従来のキーワードであるonlyとexceptはまだサポートされていますが、同じジョブでルールと一緒に使用することはできません。
    • キャッシュアーティファクトを使用して、ジョブやステージ間の情報をパイプラインで永続的に維持します。これらのキーワードは、各ジョブでエフェメラルランナーを使用している場合でも、依存関係やジョブ出力を保存するための方法です。
    • .gitlab-ci.ymlの完全な構文については、.gitlab-ci.ymlの完全なリファレンス・トピックを参照してください。

実際のプロジェクトへの適用に際して

WEB IDEでテンプレートが用意されているため、プロジェクトに適したテンプレートを選択すると楽にCI/CDを導入できそうです。

感想

Github Actionsの.github/workflows とあまり変わらないのでYAMLの定義は難しくなさそうと感じましたが、日本語のドキュメントが少なく細かい設定を調べるのが少し大変そうだなと思いました。

また、Github Actionsと異なる点として、Runnerの種類(Shared Runners・Specific Runners)があったりと色々なことができそうですが、その反面設定が複雑で分かりにくく感じました。

参考資料