【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エンコードして.(ピリオド)で繋げて表現します。

f:id:moritamorie:20220115155121p:plain

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

JWTの具体的な例

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

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

ヘッダーの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

参考資料

【Github actions】DependabotのPull RequestでSecretsが参照できずワークフローがFailになった場合の対処

Dependabot のPull Request(以下PR)が作られた際に開始したGithub Actionsワークフローが Secrets を参照できずに失敗していたので原因を調べてみました。

f:id:moritamorie:20210317022417p:plain

2021/3/1から適用になった以下のUpdateが影響していて、 Dependabot から実行される Github Actionsワークフローは読み取りだけが可能な GITHUB_TOKEN のみ使うことができ、いかなる Secrets も使えなくなるという変更が原因でした。

github.blog

なので、例えばpushイベントトリガーで実行されるワークフローの中で Secrets として追加しておいたPersonal access tokensを使って、取得したカバレッジのサマリをコメントで追加したり、自動でラベルを追加するといった書き込み(write)権限が必要な場合は、ワークフローが落ちる状況になっていました。

結論

以下のように

  • dependabotから実行されされた場合はpull_request_targetトリガーの時のみ実行
  • dependabot以外から実行された場合は pull_request_targetトリガー以外の時に実行

するようにしました。

on: 
  push:
  pull_request_target:
  workflow_dispatch:

name: Test
jobs:
  test:
    runs-on: ubuntu-latest
    if: (github.event_name == 'pull_request_target' && github.actor == 'dependabot[bot]') || (github.event_name != 'pull_request_target' && github.actor != 'dependabot[bot]') 
    steps:
      - uses: actions/checkout@v2
        if: (github.event_name == 'pull_request_target' && github.actor == 'dependabot[bot]')
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          fetch-depth: 0
      - uses: actions/checkout@v2
        if: (github.event_name != 'pull_request_target' && github.actor != 'dependabot[bot]')
        with:
          fetch-depth: 0
      - name: SonarCloud Scan
        uses: SonarSource/sonarcloud-github-action@master
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

actions/checkoutのオプションに ref: ${{ github.event.pull_request.head.sha }} がないと、 mainブランチをチェックアウトしてしまうようなので pull_request_targetの場合は同時にこのオプションを付けるようにすると良いようです。(参照

このように対応することに至った具体的な経緯を共有していきます。

失敗していた原因

実行結果の詳細を見るとSet the SONAR_TOKEN env variable.というエラーが発生しており、 Secrets に設定していた値が取れていないことが確認できました。

f:id:moritamorie:20210317025933p:plain

以下のリポジトリで、同様の状況を再現してみましたので、よろしければ参考にしてみてください。

Bump axios from 0.19.1 to 0.21.1 by dependabot · Pull Request #1 · moritamori/dependabot-testing · GitHub

対応方針の検討

主に以下の3つのドキュメント・Github issueを参照しました。

これらの情報から大きく分けて3通りの対応方法があることが分かりました。

また、①と②のどちらを適用するかは場合によって異なります。いずれの場合もwrite権限が付与され Secrets にアクセスできるようになる、という点は変わりません。

  • PR自体を更新する必要がある場合
    • => ②workflow_run
  • コメントやラベル等を追加しPRを構成するだけで更新しない場合
    • => ①pull_request_target

pull_request_target トリガーを使う

2021/2/19に公開されたGithubブログの記事 によると、write(書き込み)権限があるトークンが必要な場合、以下の2020/12/15に公開されたGithubブログの記事に書いているリスクを把握したうえで pull_request_target を使ってください、という記述があったのでリスクに関する記述を読んでみました。

リスクに関する記述1: 適用用途

pull_request_target トリガーに関して以下の記述がありました。

The reason to introduce the pull_request_target trigger was to enable workflows to label PRs (e.g. needs review) or to comment on the PR. The intent is to use the trigger for PRs that do not require dangerous processing, say building or running the content of the PR.

pull_request_targetトリガを導入した理由は、ワークフローでPRにラベルを付けたり(例:needs review)、PRにコメントを付けたりできるようにするためでした。このトリガーは、PRの内容を構築したり実行したりするような危険な処理を必要としないPRに使用することを意図しています。

今回遭遇したエラーは、この用途に該当するGithubワークフローで発生したものでした。

リスクに関する記述2: 注意点

pull_request_target トリガーの中でどのような処理を実行しても良いかを考えるうえで、以下の記述が参考になりました。

a workflow triggered on pull_request_target still has the read/write repository token in memory that is potentially available to any running program.

If the workflow uses actions/checkout and does not pass the optional parameter persist-credentials as false, it makes it even worse.

The default for the parameter is true. It means that in any subsequent steps any running code can simply read the stored repository token from the disk.

日本語にすると

pull_request_target でトリガーされたワークフローでは、メモリ内に読み取り/書き込み可能なリポジトリトークンが残っており、実行中のプログラムから利用できる可能性があります。

ワークフローが actions/checkout を使用していて、オプションのパラメータ persist-credentials を false にしていない場合は、さらに悪い状況になります。このパラメータのデフォルトはtrueです。

これは、後続のステップで、実行中のコードがディスクから保存されたリポジトリトークンを単純に読み取れることを意味します。リポジトリへの書き込みアクセスやシークレットが必要ない場合は、pull_request トリガーを使用してください。

という感じかと思います。

workflow_run を使う

リスクに関する記述1: 適用用途

workflow_run トリガーに関して以下の記述がありました。

Together with the pull_request_target, a new trigger workflow_run was introduced to enable scenarios that require building the untrusted code and also need write permissions to update the PR with e.g. code coverage results or other test results.

pull_request_target と共に、新しいトリガ workflow_run が導入され、信頼できないコードの構築を必要とするシナリオや、コードカバレッジの結果やその他のテスト結果などで PR を更新するための書き込み権限を必要とするシナリオを可能にしました。

Dependabot によって実行される Github actions ワークフローの中でPRを更新するようなケースでは workflow_run を使うと良さそうです。

リスクに関する記述2: 注意点

workflow_run トリガーの中でどのような処理を実行しても良いかを考えるうえで、以下の記述が参考になりました。

To do this in a secure manner, the untrusted code must be handled via the pull_request trigger so that it is isolated in an unprivileged environment.

これを安全に行うためには、信頼できないコードは、権限のない環境で隔離されるように、pull_requestトリガを介して処理されなければなりません。

③Dependabotを諦めてRenovateに乗り換える

対応が煩雑なので Renovate への置き換える方もでてきているようです、合わせて検討すると良いかもしれません。

github.com

対応したコード

用途に適した対応が①pull_request_target トリガーを使うだったので、Github issueコメント記載されていた対応コードのサンプルを参考に対応してみました。

Dependabot cant read secrets anymore · Issue #3253 · dependabot/dependabot-core · GitHub

ワークフローが正常に動くようになることを確認した検証用リポジトリも共有しておきます。 github.com

private registriesを使う場合

2021/3/15に以下のリリースがあり、GitHub Packagesやnpm等のプライベートレジストリを使ってパッケージの更新を行っている場合はよりセキュアに Secrets を使うことができるようになったので、こちらの方法と併せて対応すると良さそうです。

github.blog

Dependabot secretsの検証

もしかすると、 private registriesで使用可能な以下の DependabotSecrets を設定することで、Github actions ワークフローから参照できるようにならないかな、と思って試してみたのですがうまくいきませんでした。dependabot.ymlの中からしか参照できないようです。

f:id:moritamorie:20210317125542p:plain

Github actions ワークフローで行っている Secrets を使った処理を Dependabot の機能に移せる場合に、 Dependabot のprivate registriesの使用を検討すると良さそうです。

検証した際のGithubリポジトリを以下に記載しておきます。

Bump axios from 0.19.1 to 0.21.1 by dependabot · Pull Request #1 · moritamori/dependabot-testing3 · GitHub

参考資料

DockerイメージをAmazon ECRパブリックレジストリで公開してみた

2020年12月にリリースが発表されたAmazon ECRのパブリックレジストリを試したくて、 cawsay (牛(cow)に喋らせる(say)というジョークコマンド) を実行するだけのDockerファイルをビルドしてイメージを公開してみました。手順を共有してみたいと思います。

f:id:moritamorie:20210314155207p:plain

以下がリリースされた際の公式のブログ記事です。 aws.amazon.com

モチベーション

コンテナイメージからLambda functionを作れるようになったこともあり、AWSリージョン内からのECRの利用用途が広がっているので、パブリックなDockerイメージにも適用範囲を広げられそうということで試してみました。(参照)

なお、任意のAWSリージョン内からのPullは無料とのことで、ECS, EKSやLambdaなどAWSのプラットフォームから公開されているDockerイメージを使う場合には良さそう。 aws.amazon.com

また、Docker Hubのように6時間の間に100回までしかDocker pullできないなどの利用制限が無いところや、ECSやEKSでプライベートなECRリポジトリを使っている場合DockerイメージをAWSに集約できるのも運用面で嬉しい点です。

前提

リポジトリを作成

ecr-publicのサブコマンド create-repositoryを使って新しいリポジトリ moritamorie-cawsay を作ります。リージョンに ap-northeast-1を指定すると失敗したのでus-east-1に作りました。(2021年3月時点での実行結果)

$ aws ecr-public create-repository --repository-name \ 
                 moritamorie-cawsay --region ap-northeast-1

Could not connect to the endpoint URL: 
          "https://api.ecr-public.ap-northeast-1.amazonaws.com/"
$ aws ecr-public create-repository --repository-name \
                 moritamorie-cawsay --region us-east-1
{
    "repository": {
        "repositoryArn": "arn:aws:ecr-public::434137281992:repository/moritamorie-cawsay",
        "registryId": "434137281992",
        "repositoryName": "moritamorie-cawsay",
        "repositoryUri": "public.ecr.aws/w2q8j1y6/moritamorie-cawsay",
        "createdAt": "2021-03-14T17:07:57.039000+09:00"
    },
    "catalogData": {}
}

Dockerfileを作る

Dockerホストの任意のディレクトリ(ここで は cowsay としました)に、以下のDockerfileを作ってみます。

FROM debian:buster

RUN apt-get update && apt-get install -y cowsay

プッシュコマンドを確認

AWS consoleにログインして、Amazon ECRのところから作成したリポジトリを選択すると「プッシュコマンドの表示」というボタンがあるので押して、コマンドを確認しておきます。 f:id:moritamorie:20210314145915p:plain

プッシュコマンドを実行

表示されたプッシュコマンドを順次実行していきます。

ログイン

AWS CLI で認証トークンを取得し、レジストリに対して Docker クライアントを認証します。

$ aws ecr-public get-login-password --region us-east-1 | \
  docker login --username AWS --password-stdin public.ecr.aw

Dockerビルド

Dockerファイルをビルドして、イメージを生成します。

$ docker build -t cawsay .

タグ付け

作成したECRのパブリックリポジトリにイメージをプッシュできるように、イメージにタグをつけます。

$ docker tag moritamorie-cawsay:latest public.ecr.aws/w2q8j1y6/moritamorie-cawsay:latest

プッシュする

新しく作成した AWS リポジトリにこのイメージをプッシュします。

$ docker push public.ecr.aws/w2q8j1y6/moritamorie-cawsay:latest

※プッシュした後に、ログイン状態をそのままにしておく認証情報がなくてハマることがあるようなので、 プッシュ後docker logout することをお勧めします。(参照

Docker runでcawsayを実行

公開リポジトリにプッシュしたイメージを使って、cawsay 実行して"Moo"と言わせてみます。

$ docker run -it public.ecr.aws/w2q8j1y6/moritamorie-cawsay:latest \
  /usr/games/cowsay "Moo"

f:id:moritamorie:20210314151827p:plain

非常に有用な?イメージを公開できた気がします。

費用

参考までに、2021年3月時点での費用の一例を載せておきます。

費用はストレージとデータ転送の2つに分かれています。 以下の費用は、米国東部 (バージニア北部)[us-east-1]リージョンにおける費用です。

  • ストレージ
  • 月間の無料ストレージ
    • 50 GB
  • 月間の無料ストレージ以上の利用

    • GB/月あたり 0.10USD
  • データ転送

    • AWS アカウントを使用しない場合
      • 500GBまで無料
    • AWS アカウントを使用する場合
      • AWS 以外のリージョン
        • 5 TB/月まで無料
        • 5TB/月を超えるデータは、0.09 USD/GB
      • 任意の AWS リージョンへの任意の量のデータ
        • 無料

詳細は公式の以下のページ参照してみてください。料金の例を元に詳細が記載されています。

aws.amazon.com

ECR Publicのコマンド

以下のドキュメントにまとまっています。

awscli.amazonaws.com

Amazon ECR Public Gallery

作成したリポジトリAmazon ECR Public Galleryから参照可能です。

gallery.ecr.aws

参考資料

【Golang】共有された変数を平行的に安全するには(単一のゴルーチンに閉じ込める、相互排他)

プログラミング言語Go」オンライン読書会で学んだ第9章「共有された変数による平行性」に関して共有したいと思います。

並行的に安全(concurrency-safe)とは

プログラミング言語Go」の一部を引用すると

二つ以上のゴルーチンから同期を加えることなく呼び出された場合であっても正しく動作を続けるのであれば、その関数は並行的に安全(concurrency-safe)です。

という記載があります。

例えば

  • 月予算
  • 週予算
  • 今月中に使った金額

という3つの変数を扱うデータ構造を実装することを考えてみます。

このデータ構造は

  • 月予算 >= 週予算
  • 今月中に使った金額 <= 月予算
  • 今月中に使った金額が月予算に達していれば、週予算も達している

という条件を満たす必要があり、複数のゴルーチンでこのデータ構造に操作を加えてもこれらの条件が必ず満たせれば並行的に安全と言えます。

実装

以下のような実装にしたとすると、これは並行的に安全ではありません。

type CostManager struct {
    MonthlyBudget uint
    WeeklyBudget  uint
    Spent         uint
}

// 週予算と月予算を設定
func (cm *CostManager) SetBudget(weeklyBudget uint, monthlyBudget uint) error {
    if weeklyBudget > monthlyBudget {
        return errors.New("週予算が月予算を超えています")
    }
    if cm.Spent > monthlyBudget {
        return errors.New("既に月予算より多い金額を使っています")
    }
    cm.WeeklyBudget = weeklyBudget // ①
    cm.MonthlyBudget = monthlyBudget
    return nil
}

// 使った金額を設定
func (cm *CostManager) SetSpent(spent uint) error {
    if cm.MonthlyBudget < spent {
        return errors.New("使った金額が既に月予算を超えています")
    }

    cm.Spent = spent
    return nil
}

// 使った金額が月予算に達している
func (cm *CostManager) IsReachedToMonthlyBudget() bool {
    return cm.MonthlyBudget <= cm.Spent
}

// 使った金額が週予算に達している
func (cm *CostManager) IsReachedToWeeklyBudget() bool {
    return cm.WeeklyBudget <= cm.Spent
}

並行的に安全ではない理由

複数のゴルーチンから同期を加えることなく操作した場合、変数が必要な条件を満たさなくなるので、並行的に安全ではありません。

1つのゴルーチンが SetBudget(週予算と月予算を設定)を実行した時にプログラム中の①の時点まで実行した時に、他のゴルーチンが予算に達したかをチェックする関数を実行した時に必要な機能を満たさなくなります。

具体的には、最初の状態が

  • 週予算: 100
  • 月予算: 300
  • 今月中に使った金額: 300

の時に別々のゴルーチンから

  • SetBudget(400, 1000) (週予算: 400、月予算: 1,000を設定)を実行し、プログラム中の①の時点(週予算を設定)まで実行
  • 予算に達したかチェックする関数を実行

すると以下の図の状態になり、「今月中に使った金額が月予算に達していれば、週予算も達している」という要件を満たしていません。

f:id:moritamorie:20210312233329p:plain

対処方法

この問題に対処する方法を2つあります。

  • 変数を単一のゴルーチンに閉じ込める
  • 相互排他(mutual exclusion)の高度な不変式 (invariant) を維持する

変数を単一のゴルーチンに閉じ込める

1つ目の方法は、以下のように変数を特定のゴルーチンからしかアクセスさせなくすることで、安全にするというシンプルな方法です。

func SomeFunc() {
    // 月予算: 1,000、週予算: 100、今月使った金額: 0で初期化
    cm := CostManager{
        MonthlyBudget: 1000,
        WeeklyBudget:  100,
        Spent:         0,
    }
    // 変数 cm はこのゴルーチン内からしかアクセスさせない。
}

func main() {
    go SomeFunc()
}

また、類似の方法として、単一のゴルーチンの代わりに、パイプラインの中にアクセスする変数を閉じ込めるという方法に関しても言及されていました。パイプラインは以下のGo blogの記事で紹介されている手法です。

相互排他(mutual exclusion)の高度な不変式 (invariant) を維持する

もう1つの方法は、相互排他を用いて不変式を維持する方法です。

不変式とは

不変式とは、常に真である条件または関係です。

上記のデータ構造が持つ特有の不変式としては

  • 月予算 >= 週予算
  • 今月中に使った金額 <= 月予算
  • 今月中に使った金額が月予算に達していれば、週予算も達している

があります。

相互排他とは

不変式を維持する方法として、相互排他( sync.Mutex: ミューテックス )が提供されています。

プログラミング言語Goの一部を引用すると

ミューテックスの目的は、共有された変数のある種の不変式がプログラム実行中の重要な時点で維持されるのを保証することです。

という記載があります。

ミューテックスロックの使い方と効果

ミューテックスのロックを使うことによって、以下のように不変式を維持することができます。

var mu sync.Mutex

func SomeFunc() {
    # ①不変式が維持された状態
    mu.Lock()
    # ②一時的に不変式が破られた状態
    mu.Unlock()
    # ③不変式が維持された状態
}

複数のゴルーチンからデータ構造にアクセスできなくなり、ロックする時・ロックを解除した時に不変式が維持された状態を保証することができます。

月予算・週予算サンプルの例

最初の週予算と月予算のプログラムにミューテックスを導入するには、それぞれの関数の最初に

mu.Lock()
defer mu.Unlock()

を追加します。既にロックを獲得しているゴルーチンが存在する場合、他のゴルーチンはロックの解除待ちになります。

var mu sync.Mutex

// 週予算と月予算を設定
func (cm *CostManager) SetBudget(weeklyBudget uint, monthlyBudget uint) error {
    mu.Lock()
    defer mu.Unlock()
    if weeklyBudget > monthlyBudget {
        return errors.New("週予算が月予算を超えています")
    }
    if cm.Spent > monthlyBudget {
        return errors.New("既に月予算より多い金額を使っています")
    }
    cm.WeeklyBudget = weeklyBudget
    cm.MonthlyBudget = monthlyBudget
    return nil
}

// 使った金額を設定
func (cm *CostManager) SetSpent(spent uint) error {
    mu.Lock()
    defer mu.Unlock()
    if cm.MonthlyBudget < spent {
        return errors.New("使った金額が既に月予算を超えています")
    }

    cm.Spent = spent
    return nil
}

// 使った金額が月予算に達している
func (cm *CostManager) IsReachedToMonthlyBudget() bool {
    mu.Lock()
    defer mu.Unlock()
    return cm.MonthlyBudget <= cm.Spent
}

// 使った金額が週予算に達している
func (cm *CostManager) IsReachedToWeeklyBudget() bool {
    mu.Lock()
    defer mu.Unlock()
    return cm.WeeklyBudget <= cm.Spent
}

ミューテーションロックが再入可能(re-retrant)ではない

再入可能(re-retrant)とは以下のようにmu.Lock()、mu.Unlock()入れ子の状態にできることを指しますが、実行時にエラー(fatal error: all goroutines are asleep - deadlock)になります。

mu.Lock()
// 処理①
mu.Lock()
// 処理②
mu.Unlock()
// 処理③
mu.Unlock()

プログラミング言語Goの一部を引用すると

再入可能なミューテックスは他のゴルーチンが共有された変数へアクセスしないことを保証するでしょうが、それらの変数の追加の不変式を保護することはできません。

という記載があり、このような背景から明示的に再入できない仕様にしているものと考えられます。

参考資料