Simple minds think alike

より多くの可能性を

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

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

モチベーション

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

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

全体像

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

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

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

Slackでのアプリ設定

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

ショートカット設定

Slack APIからアプリを作成

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

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

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

ショートカットの名前、説明、Callback IDにそれぞれ任意の値を設定します。

スコープ追加

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

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

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

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

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

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

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

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

ただ、今はサーバー側の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」ショートカットを実行してみます。

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

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

[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を通じてメッセージ投稿する

Golangのコードから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のQuick Startチュートリアルをやってみた

仕事のソースコード管理に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を確認できます。 f:id:moritamorie:20220123004106p:plain

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 > パイプラインを選択すると実行されているパイプラインを表示できます。 f:id:moritamorie:20220123013942p:plain

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

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

f:id:moritamorie:20220123014715p:plain

.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を導入できそうです。 f:id:moritamorie:20220123160552p:plain

感想

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

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

参考資料

【Golang】go-oidcでHS256に対応する

以下の記事でgo-oidcというサードパーティライブラリを使用してOpenID ConnectでAuth0連携アプリケーションを構築する記事を書きましたが、JWT(JSON Web Token)署名に非対称暗号アルゴリズムを使っているRS256の対応方法だけしか書いていませんでした。今回は対称暗号アルゴリズムを使っているHS256に対応する方法を書いてみようと思います。

simple-minds-think-alike.moritamorie.com

前提

仕様

go-oidcを利用した実装の前にどのような署名検証を実現したいか、仕様の面を書いていきたいと思います。

HS256はどんなアルゴリズム

まず、HS256はどのようなアルゴリズムかというOpenID Connectとは直接的には関係がない一般的な話を書いてみようと思います。

HS256はHMAC-SHA-256 のことです。

HMACとは

まず、MAC (Message Authentication Code)はメッセージ認証コードのことです。

メッセージ認証コードは、暗号技術の一つです。対称・非対称暗号を用いてメッセージを暗号化すると機密性を得られますが、例えば通信経路の中で改竄が行われるとそれを検知できないので、暗号化によって完全性は得られません。メッセージ認証コードを使うと完全性を得られるようになります。

メッセージ認証コードは様々な方法で実現可能ですが、HMACは一方向ハッシュ関数(先頭HはHashの頭文字を取ったもの)を使って実現します。 HMAC-SHA-256(HS256) は、一方向ハッシュ関数にSHA-256を使ってメッセージ認証コードを計算する方法のことです。

HMACのプロセスと完全性の検証方法

計算方法など具体的な解説は省きますが、ResearchGateから引用したHMACのプロセス図を掲載しておきます。 https://www.researchgate.net/publication/346634579/figure/fig2/AS:965112139636738@1607112073858/Hash-Message-Authentication-Code-HMAC-process-332-Overview-of-SHA-256-SHA-256.png

メッセージ(Message)と共通鍵(Key)を元に、この図のプロセスで計算すると最終的にMAC値が算出されます。一方向ハッシュ関数の性質により、同じ鍵を持つ人ではないと同じMAC値を算出できないので、メッセージの受信者は改竄されたかを後から検証できます。(完全性が得られる。)

図の一方向ハッシュ関数(HASH Function)にSHA-256を使ったものがHMAC-SHA-256(HS256)というアルゴリズムです。

OpenID ConnectにおけるJWT署名

さて、話を一般的なメッセージ認証コードの話からOpenID Connectに戻し、JWTの署名がどのようなものになっているか、その中でHS256がどのように使われているかを確認していきます。

JWTのEncoding

JWTのEncodingの例は以下に記載されています。

ざっくり書くと以下の図のように、ヘッダー・ペイロード・署名の内容をBase64エンコードして.(ピリオド)で繋げて表現します。

JWTの最後の部分が署名部分だということが分かります。

JWTの具体的な例

ざっくりとしたEncodingの説明では具体的なイメージが湧かないと思いますので、jwt.ioというツールを使ってJWTを作ってみたいと思います。

jwt.ioを開いて、右側のDecodedのところに上からJWTのヘッダー・ペイロード・共通鍵(以下の図ではmy-common-keyの箇所)を入力すると、左側のEncodedでJWTが得られます。

ヘッダーのalgHS256になっているので、ペイロードをメッセージ、my-common-keyを共通鍵としてHS256で算出されたMAC値が署名部分になります。

署名検証の方法は以下に記載されており、JWTの受信者は共通鍵を使用することで署名検証できます。

実装

ここまで go-oidc を使って実現したいHS256の署名検証の方法を書いてきましたが、2022年1月時点で go-oidcHS256 をサポートしていないため部分的に独自で実装する必要があります。(参照)

HS256がサポートされていない背景

以下のissueを読んだところ、対称アルゴリズムと非対称アルゴリズムを混ぜるとAPIの誤用に繋がりかねないため、意図的な制約としてサポートしていないようです。

github.com

しかし、内部で署名検証に利用している go-jose ではHS256をサポートしているため、これを利用して実装できます。

実装

以下のように独自のVerifierを実装すると実現できます。 go-joseのREADMEを読むとSupported key types(Algorithm: HMAC)は []byte と記載されているので、共通鍵をバイトのスライスに変換して Verify関数の引数に渡します。

type HS256KeySet struct {
    CommonKey string
}

func (r *HS256KeySet) verify(_ context.Context, jws *jose.JSONWebSignature) ([]byte, error) {
    return jws.Verify([]byte(r.CommonKey))
}

func (r *HS256KeySet) VerifySignature(ctx context.Context, jwt string) ([]byte, error) {
    jws, err := jose.ParseSigned(jwt)
    if err != nil {
        return nil, fmt.Errorf("oidc: malformed jwt: %v", err)
    }
    return r.verify(ctx, jws)
}

func callback(code, state string) {

    // rawIDToken取得

    oidcConfig := &oidc.Config{
        ClientID:             "my-client-id",
        SupportedSigningAlgs: []string{"HS256"},
    }

    verifier := oidc.NewVerifier("https://issuer-domain.com/", 
        &HS256KeySet{CommonKey: "my-common-key"}, oidcConfig)
    idToken, err := verifier.Verify(context.Background(), rawIDToken)

    // 署名検証後のNonceの検証等
}

独自に作ったVerifierのVerifySignature関数は以下のタイミングで実行されます。

このように実装することで、OIDC認証の要件に必要なExpiryやIssuer, ClientIDのチェック等はサードパーティライブラリ(go-oidc)に任せられるようになり、署名検証の部分だけ独自のVerifierに実装できます。

参考資料

【Golang】Auth0のOpenID Connect連携アプリを作るチュートリアルをやってみた

仕事でOpenID Connect(以降OIDC)連携の実装した際、勉強のためにAuth0連携のサンプルアプリを作ってみたので、その一連の手順をまとめてみたいと思います。

手順は基本的にAuth0のチュートリアル通りなのですが、補足文章をつけて書いてみたいと思います。

手順は2022年1月時点のもので、今後Auth0の仕様変更により手順が変わる可能性がありますので、最新の情報はAuth0のドキュメントを確認してください。

サンプルで実現するもの

以下のような画面遷移のサンプルを作ります

  • ログイン画面を表示し、サインインリンクを押すとOIDC認証を開始
  • Auth0のログイン画面を表示
  • Auth0にログインしたユーザーのプロフィール情報(ニックネーム・写真)をユーザー画面に表示

f:id:moritamorie:20220110141908p:plain

既にAuth0にログインした状態で「サインイン」リンクが押された場合は、Auth0のログイン画面を経由せずにログイン後のユーザー画面に遷移します。

サンプルで実現するシステムの構成

サンプルで作る構成全体は以下のようになり、認可コードフローによってOIDC認証します。

f:id:moritamorie:20220110143321j:plain

環境

Goと使用するOIDCライブラリのバージョンは以下の通りです

  • Go 1.16
  • go-oidc 3.1.0

サンプル作成手順

Auth0側の設定、サンプルアプリケーション(Goプロジェクト側のコード)という順番で手順を書いていきます。

Auth0側の設定

Auth0側でアプリケーションを作成し、Auth0への接続情報を取得します。またAuth0ログイン後のリダイレクト先であるサンプルアプリケーションのコールバックURLを設定します。

アプリケーションの作成

メニューの「Applications」から「+Create Application」ボタンを押して、アプリケーションを作成するダイアログを表示します。

ダイアログ上でアプリケーション名を入力し、「Regular Web Application」を選択後「Create」ボタンを押し、アプリケーションを作成します。 f:id:moritamorie:20220105152048p:plain

アプリケーションの設定から取得する情報

作成されたアプリケーションの「Settings」タブを選択し、以下の3つの情報を控えておきます。

  • Domain
  • Client ID
  • Client Secret

f:id:moritamorie:20220105235044p:plain

アプリケーションに設定する内容

また「Settings」タブにおいて、Auth0にログインした後にリダイレクトするコールバックURLを設定します。

  • Callback URL
    • http://localhost:3000/callback

f:id:moritamorie:20220106115154p:plain

デフォルトの設定に関して

大体の要件では上記の設定でうまくいくと思いますが、要件に応じて変更する必要がありますがある以下の2点に関しては押さえておく必要があると思います。

  • 署名アルゴリズム
    • デフォルトではRS256で署名がされます。
    • HS256にしたい場合は設定変更及びHS256で署名検証するように実装も変更する必要があります。
  • OIDC Comfrontの設定
    • 厳密にOIDCの仕様に従うようになる設定です。デフォルトはONです。
    • 既にOIDC連携のアプリケーションの実装がある状態でAuth0に関しても連携できるようにしたいという場合で、厳密にOIDCの仕様に則っていない場合はOFFにする必要があるかもしれません。

これらはアプリケーションの「Settings」タブの下部にある「Advanced Settings」の「Auth」タブで設定できます。

サンプルアプリケーション(Goプロジェクト)

以下の機能を持つサンプルアプリケーションを作っていきます。

  • アプリケーションのログイン画面(home.html)を返すだけの無名ハンドラ
  • ログイン画面でサインインリンクを押した時のログインハンドラ
  • Auth0ログイン後のコールバック処理のハンドラ
  • コールバック処理でOIDC認証が終わった後にリダイレクトされるユーザー画面(user.html)を返すハンドラ

ディレクトリ構成

最終的なディレクトリ・ファイル構成は以下のようになります。

├── go.mod
├── go.sum
├── .env
├── main.go(認証器、ルーターを生成し、ポート3000でブラウザからのリクエストを待ち受ける)
├── web
│     ├── app
│     │     ├── user
│     │     │     └── user.go (ユーザー画面表示のハンドラ)
│     │     ├── login
│     │     │     └──login.go (ログイン処理のハンドラ)
│     │     └── callback
│     │           └── callback.go (コールバック処理のハンドラ)
│     └── template
│           ├──user.html (ユーザー画面のテンプレート)
│           └──home.html (ログイン画面のテンプレート)
└── platform
      ├── authenticator
      │     └── auth.go (認証器:Outh2設定、OIDCプロバイダー生成)
      └── router
            └── router.go (ルーター:セッション初期設定、HTMLテンプレートパス設定、パスとハンドラの対応付けを行う)

go.mod作成

空のgo.modファイルを作成し、依存関係があるライブラリを記載します。

$ go mod init github.com/moritamori/go-oidc-sample
module github.com/moritamori/go-oidc-sample

go 1.16

require (
  github.com/coreos/go-oidc/v3 v3.1.0
  github.com/gin-contrib/sessions v0.0.3
  github.com/gin-gonic/gin v1.7.4
  github.com/joho/godotenv v1.4.0
  golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
)

go.sum生成

$ go mod download

.envファイル作成

Auth0のsettings tabで取得・設定した内容を.envファイルに記載します。

# Auth0テナントドメイン
AUTH0_DOMAIN='dev-a4jtqeow.us.auth0.com'

# Auth0アプリケーションのClient ID.
AUTH0_CLIENT_ID='YOUR_CLIENT_ID'

# Auth0アプリケーションのClient Secret.
AUTH0_CLIENT_SECRET='YOUR_CLIENT_SECRET'

# アプリケーションのコールバックURL
AUTH0_CALLBACK_URL='http://localhost:3000/callback'

アプリケーションのエントリーポイント( main.go )

main.go では主に認証器、ルーターを生成し、ポート3000でブラウザからのリクエストを待ち受けます。

package main

import (
    "log"
    "net/http"

    "github.com/joho/godotenv"

    "github.com/moritamori/go-oidc-sample/platform/authenticator"
    "github.com/moritamori/go-oidc-sample/platform/router"
)

func main() {
    if err := godotenv.Load(); err != nil {
        log.Fatalf("envファイルの読み込みに失敗しました: %v", err)
    }

    auth, err := authenticator.New()
    if err != nil {
        log.Fatalf("認証器の生成に失敗しました: %v", err)
    }

    rtr := router.New(auth)

    log.Print("Server listening on http://localhost:3000/")
    if err := http.ListenAndServe("0.0.0.0:3000", rtr); err != nil {
        log.Fatalf("HTTPサーバーの起動時にエラーが発生しました: %v", err)
    }
}

ルーター作成

セッションの初期設定とHTMLテンプレートパス設定、パスとハンドラの対応付けをしています。 ログイン画面だけルーターの中で無名関数を作り、ルートパスにアクセスされた時にログイン画面のテンプレート(home.html) を返します。

package router

import (
    "encoding/gob"
    "net/http"

    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"

    "github.com/moritamori/go-oidc-sample/platform/authenticator"
    "github.com/moritamori/go-oidc-sample/web/app/callback"
    "github.com/moritamori/go-oidc-sample/web/app/login"
    "github.com/moritamori/go-oidc-sample/web/app/user"
)

// New はroutesを登録し、ルーターを返す
func New(auth *authenticator.Authenticator) *gin.Engine {
    router := gin.Default()

    // セッション初期設定
    gob.Register(map[string]interface{}{})
    store := cookie.NewStore([]byte("secret"))
    router.Use(sessions.Sessions("auth-session", store))

    // HTMLテンプレートパス設定
    router.LoadHTMLGlob("web/template/*")

    // 無名ハンドラ
    router.GET("/", func(ctx *gin.Context) {
        ctx.HTML(http.StatusOK, "home.html", nil)
    })
    // ログイン処理のハンドラ
    router.GET("/login", login.Handler(auth))
    // コールバックハンドラ
    router.GET("/callback", callback.Handler(auth))
    // ユーザーハンドラ
    router.GET("/user", user.Handler)

    return router
}

ログイン画面のHTML(home.html)

ルートパスにアクセスされた時に返るログイン画面のHTML。「サインイン」リンクがあるだけのシンプルな画面。

<div>
    <h3>Auth0 OpenID Connectサンプル</h3>
    <a href="/login">サインイン</a>
</div>

ログイン処理のハンドラ

ログイン画面の「サインイン」リンクを押すと/loginにGETリクエストが送られ、ログイン処理のハンドラで処理されます。

ログイン処理のハンドラでは、ランダムなstate値を生成し、セッションに保存後、AuthCodeURLにリダイレクトします。AuthCodeURLは、Discovery Endpoint(http://dev-a4jtqeow.us.auth0.com/.well-known/openid-configuration)のauthorization_endpointから取得したURLを使います。

package login

import (
    "crypto/rand"
    "encoding/base64"
    "net/http"

    "github.com/gin-contrib/sessions"
    "github.com/gin-gonic/gin"

    "github.com/moritamori/go-oidc-sample/platform/authenticator"
)

// Handler はログイン処理のハンドラ
func Handler(auth *authenticator.Authenticator) gin.HandlerFunc {
    return func(ctx *gin.Context) {
        state, err := generateRandomState()
        if err != nil {
            ctx.String(http.StatusInternalServerError, err.Error())
            return
        }

        // セッションにstateを保存
        session := sessions.Default(ctx)
        session.Set("state", state)
        if err := session.Save(); err != nil {
            ctx.String(http.StatusInternalServerError, err.Error())
            return
        }

        // 認証コードURLにリダイレクト
        ctx.Redirect(http.StatusTemporaryRedirect,
            auth.AuthCodeURL(state))
    }
}

func generateRandomState() (string, error) {
    b := make([]byte, 32)
    _, err := rand.Read(b)
    if err != nil {
        return "", err
    }

    state := base64.StdEncoding.EncodeToString(b)

    return state, nil
}

コールバックのハンドラ

コールバックハンドラは、Auth0の認証後にリダイレクトされてアクセスされます。

クエリパラメータによって渡される認可コードをアクセストークン、IDトークンと交換し、トークン内の署名検証(RS256)して問題がなければ、プロフィール情報をセッションに保存し、ログイン後のユーザー画面にリダイレクトします。

package callback

import (
    "net/http"

    "github.com/gin-contrib/sessions"
    "github.com/gin-gonic/gin"

    "github.com/moritamori/go-oidc-sample/platform/authenticator"
)

// Handler はコールバックのハンドラ
func Handler(auth *authenticator.Authenticator) gin.HandlerFunc {
    return func(ctx *gin.Context) {
        session := sessions.Default(ctx)
        if ctx.Query("state") != session.Get("state") {
            ctx.String(http.StatusBadRequest,
                "stateパラメータが無効です")
            return
        }

        // 認可コードをトークンに交換する
        token, err := auth.Exchange(ctx.Request.Context(), ctx.Query("code"))
        if err != nil {
            ctx.String(http.StatusUnauthorized,
                "認可コードからトークンへの交換に失敗しました")
            return
        }

        idToken, err := auth.VerifyIDToken(ctx.Request.Context(), token)
        if err != nil {
            ctx.String(http.StatusInternalServerError,
                "IDトークンの検証に失敗しました")
            return
        }

        var profile map[string]interface{}
        if err := idToken.Claims(&profile); err != nil {
            ctx.String(http.StatusInternalServerError, err.Error())
            return
        }

        session.Set("access_token", token.AccessToken)
        session.Set("profile", profile)
        if err := session.Save(); err != nil {
            ctx.String(http.StatusInternalServerError, err.Error())
            return
        }

        // ログイン後のユーザー画面にリダイレクト
        ctx.Redirect(http.StatusTemporaryRedirect, "/user")
    }
}

認証情報作成(Outh2設定、OIDCプロバイダー生成)

以下の2つを生成し、それらのデータを持つ認証器を返します。

  • OpenID Discoveryを元にOIDC認証を行うプロバイダー
  • OAuth接続の設定情報(Auth0側で取得・設定したClient ID、Client Secret、Callback URLなどです)

また、この認証器にはIDトークンを検証し、問題がなければIDトークンを返す関数(VerifyIDToken)があります。

package authenticator

import (
    "context"
    "errors"
    "os"

    "github.com/coreos/go-oidc/v3/oidc"
    "golang.org/x/oauth2"
)

// Authenticator はユーザーを認証するための認証器
type Authenticator struct {
    *oidc.Provider
    oauth2.Config
}

// New はユーザーを認証するための認証器を生成する
func New() (*Authenticator, error) {
    provider, err := oidc.NewProvider(
        context.Background(),
        "https://"+os.Getenv("AUTH0_DOMAIN")+"/",
    )
    if err != nil {
        return nil, err
    }

    conf := oauth2.Config{
        ClientID:     os.Getenv("AUTH0_CLIENT_ID"),
        ClientSecret: os.Getenv("AUTH0_CLIENT_SECRET"),
        RedirectURL:  os.Getenv("AUTH0_CALLBACK_URL"),
        Endpoint:     provider.Endpoint(),
        Scopes:       []string{oidc.ScopeOpenID, "profile"},
    }

    return &Authenticator{
        Provider: provider,
        Config:   conf,
    }, nil
}

// VerifyIDToken は*oauth2.Tokenが有効なトークンであることを検証する
func (a *Authenticator) VerifyIDToken(ctx context.Context,
    token *oauth2.Token) (*oidc.IDToken, error) {
    rawIDToken, ok := token.Extra("id_token").(string)
    if !ok {
        return nil, errors.New("oauth2 tokenにid_tokenフィールドがありませんでした")
    }

    oidcConfig := &oidc.Config{
        ClientID: a.ClientID,
    }

    return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
}

ユーザー画面のハンドラ

OIDC認証後にコールバックハンドラからリダイレクトされてアクセスされます。

セッションからログインユーザーのプロフィール情報を取り出し、値をテンプレートuser.htmlに渡し、生成したHTMLのレスポンスを返します。

package user

import (
    "net/http"

    "github.com/gin-contrib/sessions"
    "github.com/gin-gonic/gin"
)

// Handler はログイン済みユーザー画面のハンドラ
func Handler(ctx *gin.Context) {
    session := sessions.Default(ctx)
    profile := session.Get("profile")

    ctx.HTML(http.StatusOK, "user.html", profile)
}

ユーザー画面のHTML(user.html)

ユーザー画面のハンドラから読み込まれるユーザー画面のHTMLテンプレート。ハンドラからプロフィール情報を受け取り、展開します。

<div>
    <img class="avatar" src="{{ .picture }}"/>
    <h2>Welcome {{.nickname}}</h2>
</div>

gitリポジトリにpushする

事前にGithubリポジトリを作って、PUSHしておきます。

作ったサンプルを動かしてみる

$ go run main.go
[GIN-debug] Loaded HTML Templates (3):
    -
    - home.html
    - user.html

[GIN-debug] GET    /            --> github.com/moritamori/go-oidc-sample/platform/router.New.func1 (4 handlers)
[GIN-debug] GET    /login       --> github.com/moritamori/go-oidc-sample/web/app/login.Handler.func1 (4 handlers)
[GIN-debug] GET    /callback    --> github.com/moritamori/go-oidc-sample/web/app/callback.Handler.func1 (4 handlers)
[GIN-debug] GET    /user        --> github.com/moritamori/go-oidc-sample/web/app/user.Handler (4 handlers)
2022/01/06 14:56:21 Server listening on http://localhost:3000/

http://localhost:3000Webブラウザからアクセスするとログイン画面が表示され、サインインリンクをクリックし、OIDC連携の動作を確認できます。

f:id:moritamorie:20220106102448p:plain

リポジトリ

コードをGithubリポジトリにあげてみたのでよろしければ参考にしてみてください。 github.com

関連記事

本記事内では署名検証にHS256に対応する方法も記載してみましたので、もしよろしければご参考ください。

simple-minds-think-alike.moritamorie.com

参考資料 

PDFファイル構造の概要

PDFファイルの構造を知りたくてAdobe社から公開されているPDF 1.7の仕様書を少し読んだのですが、ボリュームが多く、英語で書かれていて理解が進みづらいと感じていました。

日本語の本とかないのかなと探していたらO'REILLYから「PDF構造解説」という書籍が2012年に出版されていていて、248ページという適度なボリュームで良さそうだと思って読んでみました。

また、この本の特徴として、PDFコンテンツのオブジェクト間の繋がりを有向グラフで表現していて、PDFの表示の仕組みに関しても理解が進みやすくなっているように工夫されている点が良いなと感じました。

この書籍の主に3章(ファイル構造)を読んで把握できた点をまとめてみようかと思います。

環境

  • macOS Catalina 10.15.7
  • pdftk 2.02

PDFファイルの構造

PDFファイルはテキストデータとバイナリデータの両方を含むことができます。

組み込みのフォントや画像などのデータはバイナリデータですが、PDFファイルの構造や文章はテキストデータになっているので、テキストエディタで開くことでざっくり内容を確認することができます。

PDFファイルを開くと以下の4つの部分から構成されていることが分かります。

  • ヘッダー
  • ボディ
  • クロスリファレンステーブル
  • トレイラー

f:id:moritamorie:20211024004420p:plain

ヘッダー

最初の行では準拠するPDF仕様のバージョン(ここでは1.0)を指定します。

%PDF-1.0

ボディ

ボディ部分は一連のオブジェクト(ページ、注釈、画像、署名など)で構成され、PDFビューワーで表示されるテキストや画像などのコンテンツはボディ内のオブジェクトとして指定します。

それぞれのオブジェクトの1行目で「オブジェクト番号世代番号objキーワード」を指定します。最後の行で「endobjキーワード」を指定し、その間がオブジェクトのコンテンツになります。以下のオブジェクトでは <</Kids [2 0 R] /Count 1 /Type /Pages >> という辞書(後述)がコンテンツです。

1 0 obj
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj

クロスリファレンステーブル

クロスリファレンステーブルでは、ボディ内の各オブジェクト毎にファイルの先頭からのバイトオフセットを保持しています。

この情報によってランダムアクセスが可能になるため、PDFビューワーはファイルの一番上から順番に処理しなくても、任意のページを高速に表示することができます。

xrefキーワードから始まり、次の行(0 7)でテーブル内のエントリ(0から始まる7つのエントリがある)を表します。 その後に続く3列の項目では、各オブジェクトの バイトオフセット世代番号最後の1文字で使用・未使用(fが未使用、nが使用する)を表します。

xref
0 7
0000000000 65535 f 
0000000015 00000 n 
(以下、省略)

※最初のエントリ(0000000000 65535 f)はスペシャルエントリを表しており、使用しません。次のエントリからオブジェクト1、オブジェクト2…とそれぞれのバイトオフセットを保持します。

トレイラー

トレイラーは、クロスリファレンステーブルへのバイトオフセットを保持しており、直接アクセス可能にすることで高速にPDFファイルを開くための役割を果たしています。

trailerキーワードから始まり、次にトレーラー辞書が続きます。

トレーラー辞書は、以下2つのエントリが必須です。

  • /Size エントリ(クロスリファレンステーブルのエントリ数)
  • /Rootエントリ(ルート要素であるドキュメントカタログのオブジェクト番号)

その後、startxrefキーワードが続き、次の行でクロスリファレンステーブルのバイトオフセット、最後行を表す%%EOFが記述されます。

trailer
<< /Size 7
   /Root 5 0 R
>>
startxref
612
%%EOF

PDFファイルの基本要素

ここからはボディやトレイラーといったPDFファイルの一部ではなく、PDFファイル全体を通して使われる基本要素に関してまとめてみます。

単一の要素

整数や文字列といったそれ単体で表現される要素です。 整数や実数、文字列、名前、ブーリアン値、nullの5つがあります。

整数や実数

例: 43, 1.4142

文字列

丸括弧で囲まれると文字列になります。
例: (Lucy in the Sky)

名前

"/"から始まります。項目の識別子で、辞書のキーや様々な用途に用いられます。
例: /Kids/Count/Size

ブーリアン

trueキーワード または falseキーワード

null

nullキーワード指定されます。

複合的な要素

配列や辞書、ストリームの3つがあり、要素を複合的に使用します。

配列

ブランケットで囲まれると配列になります。 整数や文字列、間接参照といった要素以外にも配列や辞書も含めることができます。
例: [2 0 R 1 0 R]
上記は、1つ目の要素が 間接参照2 0 R、2つ目の要素が間接参照 1 0 R、という2つの要素から構成される長さ2の配列。

辞書

不等号を2つ続けたもの( << , >> )で囲うと辞書になります。名前から他の値に対応づけられます。配列や辞書も値にできます。

例:

<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>

上記は、/Kids が間接参照2 0 Rを含む配列、 /Countが整数1、/Typeが名前Pages に対応づけられた辞書。

ストリーム

ストリームは、フォントや画像、図形などを格納するものです。
streamendstreamの間に画像のバイナリデータなどの実データを指定し、辞書とセットで記述されます。
辞書には、データ長や圧縮方式といったストリームに関するメタデータを格納します。

3 0 obj
<<
 /Length 2817
>>
stream
(実データ)
endstream
endobj

参照する要素

関節参照

他のオブジェクトのリンクを作成します。
例: 1 0 R (オブジェクト番号1、世代番号0のオブジェクトへのリンクを表します。)

テキストエディタとpdftkを使ってPDFファイルを作る

ここまでのPDFファイルの構造、基本要素の内容を把握した段階で、大部分はテキストデータで記述できることが分かったので、簡単なPDFファイルをテキストエディタで作ってみました。

ただ、クロスリファレンステーブル内、トレーラー内のバイトオフセットを自分で算出して記載するのは手間だったので pdftk というコマンドラインツールを補完的に使いました。

pdftkのインストール

pdftk のインストール方法を調べたところ Homebrewを使った方法は動かなくなっているようだったので以下のパッケージファイルをダウンロードしてインストールしました。(Stackoverflow参照)

https://www.pdflabs.com/tools/pdftk-the-pdf-toolkit/pdftk_server-2.02-mac_osx-10.11-setup.pkg

テキストエディタでPDF作成

以下のグラフような構造のPDFファイルを作ってみます。

f:id:moritamorie:20211024100512p:plain

%PDF-1.0

1 0 obj 
<< /Type /Pages
   /Count 1
   /Kids [2 0 R] 
>>
endobj

2 0 obj 
<< /Type /Page
   /MediaBox [0 0 650 840]
   /Resources 3 0 R
   /Parent 1 0 R
   /Contents [4 0 R]
>>
endobj

3 0 obj 
<< /Font
     << /DF
          << /Type /Font
             /BaseFont /Helvetica
             /Subtype /Type1 >>
     >>
>>
endobj

4 0 obj
<< >>
stream
1. 0. 0. 1. 10. 800. cm
BT
  /DF 30. Tf
  (Hello, PDF!) Tj
ET 
endstream
endobj

5 0 obj
<< /Type /Catalog
   /Pages 1 0 R
>>
endobj
xref

0 6
trailer
<< /Size 6
   /Root 5 0 R
>>
startxref
0
%%EOF

PDFビューアーで開くと以下のような表示になります。 f:id:moritamorie:20211024102013p:plain

sample.pdfの内容

トレーラー

トレーラー辞書の/Rootには間接参照5 0 Rが対応づけられており、ルート要素であるドキュメントカタログのオブジェクト番号は5であることが分かります。

trailer
<< /Size 6
   /Root 5 0 R
>>
ドキュメントカタログ

ページツリーのオブジェクトの参照( 1 0 R )が対応づけられています。

5 0 obj
<< /Type /Catalog
   /Pages 1 0 R
>>
endobj
ページツリー(Pages)

オブジェクト番号2であるのページだけから構成されるページツリーです。

1 0 obj 
<< /Type /Pages
   /Count 1
   /Kids [2 0 R] 
>>
endobj
ページ(Page)

用紙サイズ650x840、リソースオブジェクトの参照(3 0 R )、親ページツリーの参照( 1 0 R )、コンテンツの参照( 4 0 R )が指定されています。

2 0 obj 
<< /Type /Page
   /MediaBox [0 0 650 840]
   /Resources 3 0 R
   /Parent 1 0 R
   /Contents [4 0 R]
>>
endobj
フォントリソース

名前が /DF、BaseFontが Helvetica のフォントリソースを指定しています。

3 0 obj 
<< /Font
     << /DF
          << /Type /Font
             /BaseFont /Helvetica
             /Subtype /Type1 >>
     >>
>>
endobj
コンテンツ(Hello, PDF!)

streamキーワードの次の行で、座標を右に10、上に800移動しています。また、BT(Begin Text)とET(End Text)の間で、30ポイントのフォント/DFを選択し、テキスト文字列(Hello, PDF!)を描画しています。

4 0 obj
<< >>
stream
1. 0. 0. 1. 10. 800. cm
BT
  /DF 30. Tf
  (Hello, PDF!) Tj
ET 
endstream
endobj

pdftkを使って補完

このままでもPDFファイルとして開くことはできるのですが、バイトオフセットが入っておらず任意のページにランダムアクセス可能なPDFになっていないのでpdftkを使って補完してみます。(といっても、作成したsample.pdfのページ数1はなので体感できるような表示速度の向上はありません。)

以下を実行しました。

$ pdftk sample.pdf output converted.pdf

出力されたPDFファイルをテキストエディタで開くと以下のようになり、クロスリファレンステーブルが作成され各オブジェクトのバイトオフセット、startxrefの箇所にクロスリファレンステーブルへのバイトオフセット等の情報が入っていることが確認できます。

感想

膨大な英語のドキュメントを読んでいると辛くなってくるので、日本語で書いてある資料は貴重だと思いました。

ただこの本だけでPDF仕様全体を把握できないので入門的な位置付けで捉えておいて、都度SDKのドキュメントを読んだり、仕様書を読んだりするのが良さそうです。

参考資料

【Golang】UniPDFでPDFファイルにデジタル署名する

PDFファイルにデジタル署名ができるGo言語のライブラリ( UniPDF )を使ってみました。

今年(2021年)4月頃に従量課金プランにフリー層ができたとブログ記事に掲載があり、月100件は無料で使える状態になったようです。

以下の公式のブログ記事を参考にサンプルコードを試してみました。 unidoc.io

UniPDF の署名機能を使うと、具体的には以下のようなことができるようです。 (参照)

  1. RSAキーペアによるドキュメントへの署名
  2. PKCS#12ファイルを使用したドキュメントへの署名
  3. 外部の署名生成サービスを利用して文書に署名し、空白の署名欄に署名を付加する
  4. SoftHSMとCrypto11パッケージを使ったPKCS#11サービスによる文書の署名
  5. PDFの電子署名の検証

今回は 「1. RSAキーペアによるドキュメントへの署名」のExampleコードを実行し、内容を確認してみようと思います。

結果

既存のPDFファイルに対してExampleコードを実行したところ、以下のような署名付きのPDFファイルが出力されました。

ページ右下に背景が黄色の署名フィールドが追加され、署名パネルで追加した署名の内容を確認できました。

f:id:moritamorie:20211010232009g:plain

環境

  • Go v1.16.2
  • UniPDF v3.29.0

事前準備

実行するには事前にAPIキーを発行して、環境変数 UNIDOC_LICENSE_API_KEY を設定する必要があります。 Uni Cloudのsignup画面から登録し、以下のAPIキーの画面からAPIキーを発行します。

f:id:moritamorie:20211010233047p:plain

コードの確認

サンプルコードがやっていることをコードスニペットに分解して見ていこうと思います。

①パッケージの読み込み

unidoc/pdf/core, unidoc/pdf/modelは基本的なPDFタイプとドキュメントモデルを提供してくれているようです。 (詳細はUniPDF v2のPress Releaseを参照)

import (
    "crypto/rand"
    "crypto/rsa"
    "crypto/x509"
    "crypto/x509/pkix"
    "fmt"
    "log"
    "math/big"
    "os"
    "time"

    "github.com/unidoc/unipdf/v3/annotator"
    "github.com/unidoc/unipdf/v3/common/license"
    "github.com/unidoc/unipdf/v3/core"
    "github.com/unidoc/unipdf/v3/model"
    "github.com/unidoc/unipdf/v3/model/sighandler"
)

②ライセンスAPIキー(環境変数)の読み込み

先ほど設定した UNIDOC_LICENSE_API_KEY環境変数から読み込みます。

func init() {
    err := license.SetMeteredKey(os.Getenv(`UNIDOC_LICENSE_API_KEY`))
    if err != nil {
        panic(err)
    }
}

RSAキーペアとX.509証明書

generateKeys()を実行し、RSAキーペアとX.509証明書を生成します。generateKeys()の中身は折りたたんで掲載しておきます。

   priv, cert, err := generateKeys()
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

④PDFリーダーと署名の生成

PDFリーダー、署名を付加するためのアペンダーを作成します。また、③で作成した秘密鍵と証明書を元に署名ハンドラを作成し、署名ハンドラを元にデジタル署名を作成します。最後にデジタル署名を初期化します。

   file, err := os.Open(inputPath)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }
    defer file.Close()

    reader, err := model.NewPdfReader(file)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

    appender, err := model.NewPdfAppender(reader)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

    handler, err := sighandler.NewAdobePKCS7Detached(priv, cert)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

    signature := model.NewPdfSignature(handler)
    signature.SetName("Test Self Signed PDF")
    signature.SetReason("TestSelfSignedPDF")
    signature.SetDate(now, "")

    if err := signature.Initialize(); err != nil {
        log.Fatal("Fail: %v\n", err)
    }

⑤署名の挿入

最後にPDFファイルにデジタル署名を行い、アノテーションを挿入し、ファイルを出力します。

   opts := annotator.NewSignatureFieldOpts()
    opts.BorderSize = 1
    opts.FontSize = 10
    opts.Rect = []float64{475, 25, 590, 80}
    opts.FillColor = model.NewPdfColorDeviceRGB(255, 255, 0)
    opts.TextColor = model.NewPdfColorDeviceRGB(0, 0, 200)

    field, err := annotator.NewSignatureField(
        signature,
        []*annotator.SignatureLine{
            annotator.NewSignatureLine("Name", "Takashi Morita"),
            annotator.NewSignatureLine("Date", "2021.10.10"),
            annotator.NewSignatureLine("Reason", "Looks good to me"),
            annotator.NewSignatureLine("Location", "Tokyo"),
        },
        opts,
    )
    field.T = core.MakeString("Self signed PDF")

    if err = appender.Sign(1, field); err != nil {
        log.Fatal("Fail: %v\n", err)
    }

    err = appender.WriteToFile(outputPath)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

コード全体

最後にコード全体を掲載しておきます。

サンプルコードの実行

$ go run main.go sample.pdf signed.pdf

出力結果の確認

出力されたPDFファイルをテキストエディタで開いたところ署名オブジェクトは以下のようになっていました。

14 0 obj
<<   /Type /Sig
     /Filter /Adobe.PPKLite
     /SubFilter /adbe.pkcs7.detached
     /Contents (省略)
     /Name (Test Self Signed PDF)
     /Reason (TestSelfSignedPDF)
     /M (D:20211010231753+09'00')
     /ByteRange [0 3457 19843 740]
>>

PDF 1.7の仕様書のDigital Signatureの章を参考にすると以下の値が入るようです。ByteRangeに関しては仕様書読んでもよくわからなかった。。

キー タイプ
Type name (オプション)Sigが入る
Filter name (必須) この署名を検証する際に使用する優先的な署名ハンドラの名前。適合するリーダーは、指定されたサブフィルターフォーマットをサポートしている限り、署名を検証する際に別のハンドラーで代用することができる。署名ハンドラーの例としては、Adobe.PPKLITEEntrust.PPKEFCICI.SignItVerisign.PPKVSがある。
SubFilter name (オプション) 署名辞書の署名値とキー情報のエンコーディングを記述した名前。適合するリーダーは、このフォーマットをサポートする任意のハンドラーを使用して、署名を検証することができる。
Contents byte string (必須) 署名の値。ByteRangeが存在する場合、値はバイトレンジダイジェストの値を表す16進文字列。
M date (オプション)署名の時間。署名ハンドラによって、検証されない通常のコンピュータの時刻か検証可能な方法で安全なタイムサーバから生成された時刻になる。
ByteRange array (署名フィールドの一部であるすべての署名、および権限辞書のUR3エントリから参照される使用権署名に必要) ダイジェスト計算のための正確なバイト範囲を記述する整数のペア(開始バイトオフセット、バイト単位の長さ)の配列。

また、最近のAdobeプロダクトのバージョン(11.x以降)では、RSAだと adbe.pkcs7.detachedETSI.CAdES.detached がSubFilterとして適合しているようでした。

感想

思ったより簡単に既存のPDFにデジタル署名することができました。RSAやX.509証明書を作る部分はGo言語の標準パッケージ(crypto/rsa, crypto/x509)に任せることができるのが理由なのかもと思いました。

また、長期署名である PAdES に関しても将来的にサポートを予定しているようなので、UniPDFの今後のアップデートに期待したいです。

関連記事

PDFファイルの内容の確認の仕方は以下の記事にまとめていますので、よろしければご参照ください。

simple-minds-think-alike.moritamorie.com

参考資料