Simple minds think alike

より多くの可能性を

【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