iOS/macOS アプリ開発時のコードサインで発生しがちな手作業で面倒な部分をちょこっと自動化する。

概要

  • 秘密鍵周り、証明書周り、コードサインの概要は 分かっている 、という前提で書いている。

  • 組織的な事情によって Automatically manage signing が上手く機能しない場合が割と稀によくある。

  • 実際に調査したり試行錯誤していたのは 2018 年の 11 月中頃の話なのだけど、まとめといた方がいいなと思って情報をまとめた。

  • 手元の開発機になりがちな MacBook Pro や iMac など、他にも各所で CI として運用されがちな Mac mini など、普通に暮らしていると現代のアプリ開発環境ではビルド環境は複数に分散しがちで、それに伴って手作業メンテナンスおじさんが発生しがち。

  • 色々な運用形態があるし、チームの大きさなどの要素もあるので、 適度な自動化 をしたい。

  • これは一般論なのだが、 現状を見誤った過度な自動化 は基本的にやめた方がいいことが多い気がする。

とりあえずシェルスクリプトでやっていく。

自動化といっても色々なやり方があるが、基本的に vanilla な macOS に対して何か操作をする可能性を考えると bash スクリプトを書くのが最も汎用性が高くなると思われる。

幸い macOS にはビルトインで色々と便利なコマンドがあるので、それらを使っていくことになる。

具体的には

  • /usr/bin/security (Keychain Access のコマンドラインインターフェース)
  • /usr/libexec/PlistBuddy (plist の読み書き)

あたりが macOS 固有のものになる。

あと注意したいのは date コマンドの挙動が GNU 版のものと BSD 版のもので微妙に異なることで、 macOS にはもちろん BSD 版の coreutils が入っている。

捻くれ者(僕のことです)が homebrew で GNU 版の coreutils を入れいて普段はそちらを使っている可能性などもあるので、必ず BSD 版のものを使うように出来る限りフルパスでコマンドを指定するようにするなど細かい気遣いが必要となることもある。

シェルスクリプトを書くのに必要なのは思いやりと気遣いです。

デバッグ環境として、仮想マシン上に vanilla な macOS 環境を作る。

手始めに開発環境を用意する必要があるのだけど、残念ながらコードサインの問題をなんとかしたいと思うような人は既にコードサインの諸問題、地雷を踏み抜いていることが殆どだと思われる。

そのため、今あなたがその瞬間に手元で使っている Mac は様々な試行錯誤をした後であることはほぼ間違いない。そうすると何らかの操作の初回にしか発生しない様々な現象はもう発生しない。

これが結構癖者で、実際に「コードサイン用の秘密鍵を macOS の Keychain に import することまでは成功するけど、その秘密鍵を実際に使用する初回にだけ出るアラートを 『確認』 しないと、秘密鍵の使用に失敗する機構が Sierra から導入されてしまった」というような地雷がある。

地雷に文句を言ってもしょうがないので、仮想マシンに壊せる環境を作らないと話が進まない。

Apple から公式な情報が出ており、 macOS の起動可能なインストーラを作成する方法 なのだが、これは USB メモリをインストールディスクにする方法として紹介されている。

ここから VirtualBox にインストールするための仮想インストールメディアを作るには hdiutil コマンドを駆使してイメージを作成する必要がある。

ググればその情報は色々出てくるが、僕は ここ の情報を参考に VirtualBox 上に High Sierra の環境を作った。

各 macOS のダウンロードリンク

重要な情報だと思うのだけど、 Apple のサポートページには 意図的に まとまってない感じだったので以下にまとめておく。

ハマったこと その一

VirtualBox が 2nd EFI を持ってるディスクをうまく認識してくれない(?)らしく、 CD からの boot に fallback されてしまって永遠にインストールが始まらないという問題が発生したりした。

これは再起動したときに fn + F12 キーを最速で押してブートオプションの画面に気合で入り、手動で起動ディスクを指定してやることによって解決できる。

起動時に頑張って fn + F12 を押す。
↓
Boot Maintenance Manager
↓
Boot from File
↓
...HD(2,GPT)... となってるものを選ぶ。
↓
<macOS Install Data>
↓
<Locked Files>
↓
<Boot Files>
↓
boot.efi

ハマったこと その二

macOS 仮想インストールメディアを作ったあとに不要になった Install macOS High Sierra.app が削除できない問題が発生した。

一部ファイルが SIP によって保護されているのでマジでどうしようもない。削除するために一時的に csrutil コマンドで SIP を無効化する必要があった。こればっかりは結構つらい気持ちになった。

仮想マシンに macOS をインストールするのはライセンス的に大丈夫なの?

お手元の Mac 上で動かすなら最大2台まで大丈夫です。

Provisioning Profile を CUI でインストールする。

*.mobileprovision*.provisionprofile は素朴に cp コマンドで $HOME/Library/MobileDevice/Provisioning Profiles/ に放り込むだけで良い。

しかしそれだと芸がないので、ある程度きちんとやっておく。

$HOME/Library/MobileDevice/Provisioning Profiles/ を覗いてみると 01234567-89ab-cdef-0123-456789abcdef というようなファイル名になっていることが確認できるため、それに揃えたい。

また、Provisioning Profile には有効期限があるので、コピー時にそれらをチェックして期限が切れているものは弾きたい。

Provisioning Profile のファイル構造について。

ファイルの前半部分はただの plist で、後半部分にバイナリで証明書の情報がくっ付いている。

後半のバイナリ部分を気にしなければ cat でも読めるが、ここは素直に security コマンドを使うべきだろう。

# plist の形で出力される。
$ security cms -D -i '/path/to/your_great_app.mobileprovision'

あとはこの plist 形式の文字列から必要な値だけを読めれば良いので PlistBuddy コマンドを使う。

PlistBuddy で plist から必要な値を読む。

PlistBuddy はファイルを受け付ける形になってるので、標準入力から渡すためにちょっと工夫すると以下のような形になる。

# 説明しやすいように変数に放り込んでおく。
PLIST=$(security cms -D -i your_great_app.mobileprovision)

# mobileprovision の UUID を取る。
$ /usr/libexec/PlistBuddy -c 'Print UUID' /dev/stdin <<< $(echo $PLIST)
01234567-89ab-cdef-0123-456789abcdef

# mobileprovision の ExpirationDate を取る。
$ /usr/libexec/PlistBuddy -c 'Print ExpirationDate' /dev/stdin <<< $(echo $PLIST)
Wed Oct 02 03:47:41 JST 2019

# mobileprovision の DeveloperCertificates を取る。バイナリで来るので openssl に食わせて中身を表示する。
$ /usr/libexec/PlistBuddy -c 'Print DeveloperCertificates:0' /dev/stdin <<< $(echo $PLIST) | openssl x509 -inform der -text -noout

これを基本にして、パイプでデータを受け取る形の関数を書いてシェルスクリプトから使う際に扱いやすくしたい。

function value_for_key() {
    if [ ! -p /dev/stdin ]; then
        return 1;
    fi
    if [ -z "$1" ]; then
        return 1;
    fi
    env -i /usr/libexec/PlistBuddy -c "Print $1" /dev/stdin <<< $(cat -) 2>/dev/null
}

概ねこんな感じでいいだろう。

フォーマットされた日付の文字列からタイムスタンプに変換する。

上記の結果を見ると PlistBuddy余計な 気を利かせて日付やバイナリを適切なフォーマットにしてくれるようだ。

タイムスタンプに変換すればシェルスクリプトでも日付の比較が容易に行えるため、フォーマットされた日付の文字列からタイムスタンプに変換をする。

# BSD 版 coreutils で、環境が英語ロケールのとき、以下でタイムスタンプ化できる。
$ date -j -f '%a %b %d %T %Z %Y' 'Wed Oct 02 03:47:41 JST 2019' '+%s'
1569955661

これをパイプからデータを受けるような形の関数にして、扱いやすくしつつ、環境の差異を吸収してやると

function to_timestamp() {
    if [ ! -p /dev/stdin ]; then
        return 1;
    fi
    env -i LC_ALL='POSIX' /bin/date -j -f '%a %b %d %T %Z %Y' "$(cat -)" '+%s' 2>/dev/null
}

大体こんな感じになるだろう。

指定する環境変数は LC_ALL なのか LANG なのか、はたまた LC_TIME というのも。一体どれが正解なんだろうか…。

また指定するのは 'POSIX' なのか 'C' なのか 'en_US.UTF-8' なのか…一体何が正解なんだろうという悩みが常にある。

この辺は難しいですね。とりあえず動いているから良しとしておく。

これらを組み合わせる。

上記の関数 value_for_keyto_timestamp が定義されていて、 PLIST 変数に security コマンドで読んだ plist の中身が入っていると

Name=$(echo "$PLIST" | value_for_key 'Name')
ExpirationDate=$(echo "$PLIST" | value_for_key 'ExpirationDate' | to_timestamp)
UUID=$(echo "$PLIST" | value_for_key 'UUID')

こんな感じで Provisioning Profile から必要なデータを選んで取れるようになる。

シェルスクリプトを書く際に気を付けるべきことだが、データの受け渡しはパイプで流せるようにするのが筋が良い。

普段 Swift を書いていると引数や返値でデータの受け渡しをする世界観なので戸惑うけど、シェルスクリプトの世界ではパイプに繋げる意識を持つと良いとされる。

ここまで来てしまえば、予めリポジトリに含めておいた *.mobileprovision*.provisionprofile から有効期限内のものだけを選んで適切な名前で適切な場所に cp するスクリプトを書くのはそんなに難しいことではない。

また応用として、期限切れの Provisioning Profile を $HOME/Library/MobileDevice/Provisioning Profiles/ から抽出して削除するスクリプト、などもスッと書けるだろう。

コードサイン用の秘密鍵を CUI でインストールする。

security コマンドを使って気合いで頑張ることになる。とりあえずキーチェーンの種類を把握したい。

Keychain Access.app を触っていれば言うまでもないが "ログイン" と "システム" の 2 種類のキーチェーンがある。コマンドで確認するには以下。

# ドキュメントには無いが security list でも同じ出力が得られる。
$ security list-keychains
    "/Users/dnpp/Library/Keychains/login.keychain-db"
    "/Library/Keychains/System.keychain"

デフォルトは以下で取れる。何も弄っていなければログインキーチェーンが得られるはず。

# ドキュメントには無いが security default でも同じ出力が得られる。
$ security default-keychain
    "/Users/dnpp/Library/Keychains/login.keychain-db"

恐らく普通に使うのはこれで取れるログインキーチェーンになる。

# ドキュメントには無いが security login でも同じ出力が得られる。
$ security login-keychain
    "/Users/dnpp/Library/Keychains/login.keychain-db"

ちなみにシステムキーチェーンを取るサブコマンドはない。なんでや。

$ security system-keychain
security: unknown command "system-keychain"

security コマンドを使って秘密鍵をインポートするときは、対象となるキーチェーンを指定する必要があるため、この辺のコマンドで取っておくのが良いだろう。ログインキーチェーンの指定は ~ を使いつつ ~/Library/Keychains/login のように省略して書くことも出来るが、なんとなくコマンドで取れる方を使っておきたい。

しかし出力される文字列には余計な空白があったり " が含まれていたりして微妙に扱い辛いため、 sed コマンドあたりで簡単に加工しておく。

$ LOGIN_KEYCHAIN=$(security login-keychain | sed -e 's/^ *"//' | sed -e 's/"$//')

$ echo "$LOGIN_KEYCHAIN"
/Users/dnpp/Library/Keychains/login.keychain-db

cut コマンドの方が綺麗に書けるような気もする。

$ LOGIN_KEYCHAIN=$(security login-keychain | cut -d '"' -f2)

$ echo "$LOGIN_KEYCHAIN"
/Users/dnpp/Library/Keychains/login.keychain-db

概ねこのような感じで良いだろう。

security import の話。

GUI であればエクスポートした秘密鍵の実体である p12 ファイルをダブルクリックして Keychain Access.app を立ち上げ "ログイン" のキーチェーンに放り込むといったことをすると思うが、同じことは security import コマンドで実現ができる。

使い方は以下。

$ security import
Usage: import inputfile [-k keychain] [-t type] [-f format] [-w] [-P passphrase] [options...]
    -k  Target keychain to import into
    -t  Type = pub|priv|session|cert|agg
    -f  Format = openssl|openssh1|openssh2|bsafe|raw|pkcs7|pkcs8|pkcs12|netscape|pemseq
    -w  Specify that private keys are wrapped and must be unwrapped on import
    -x  Specify that private keys are non-extractable after being imported
    -P  Specify wrapping passphrase immediately (default is secure passphrase via GUI)
    -a  Specify name and value of extended attribute (can be used multiple times)
    -A  Allow any application to access the imported key without warning (insecure, not recommended!)
    -T  Specify an application which may access the imported key (multiple -T options are allowed)
Use of the -P option is insecure

        Import items into a keychain.

大抵の現場では秘密鍵を p12 ファイルにエクスポートするときに適当なパスフレーズをその場で指定する運用がなされているだろう。インポートするにはそのパスフレーズを入れてやる必要がある訳だが、そのためのオプションである -P について Use of the -P option is insecure と書かれている。ここら辺は自動化のためには仕方ない。

ところで -P オプションを指定しないで実行すると失敗するかと思いきや、なんと GUI でパスフレーズを入れるプロンプトが出てくる。そして ssh 越しの実行などの場合は security: SecKeychainItemImport: User interaction is not allowed. と出力され素直に失敗する。

-T で実際に秘密鍵を使用するアプリケーションを指定しておくことも重要だ。コードサインのために使う場合は '/usr/bin/codesign' で良い。代わりに -A で無制限に許可することも出来るが、こういうのはちゃんと指定をしておくのが良いだろう。

-T

要するにこの画像で設定するところの話です。

まとめると以下のような形になる。

# passphrase はどこかで用意しておく。
$ security import '/path/to/privkey.p12' -k "$LOGIN_KEYCHAIN" -t priv -P "$passphrase" -T '/usr/bin/codesign'

しかしここまでやったとしても、対象のキーチェーンがロックされている場合は秘密鍵のインポートに失敗する。

security unlock-keychain の話。

普段意識することはまずないが、キーチェーンにはロック状態という概念がある。

キーチェーンがロック状態で、秘密鍵のインポートをしようとしたり、格納されている秘密鍵を使おうとすると、キーチェーンのアンロックのための GUI プロンプトが出る。GUI のプロンプトが出せない状況、例えば ssh 越しの実行であったりするとこれらのキーチェーンに対する全ての操作は失敗する。

キーチェーンがロックされているかどうかの表示

滅茶苦茶分かり辛いのだが上の画像は、

  • "ログイン" がアンロックされている。
  • "システム" がロックされている。

ということをアイコンで示している図だ。

Keychain Access.app をよく見るとこの様に表示されていることが確認できる。

通常であればログインキーチェーンはアンロックされているように見えることが殆どであろう。ユーザーが手元の Mac にログインすると同時にログインキーチェーンもアンロックされる。

しかし ssh 経由のログインではログインキーチェーンはアンロックされない。

ssh 経由であっても公開鍵認証であればアンロックされたような気がしたのだが、不安になって手元で検証したところどうやら公開鍵認証でログインしてもログインキーチェーンはロックされたままのようだ。

使いたいキーチェーンがロックされているとありとあらゆる操作が失敗してしまうため、使う前にアンロックをしておく必要がある。

$ security unlock-keychain "$LOGIN_KEYCHAIN"

これでアンロックができる。ユーザーのログインパスワードが求められるので入れてやれば良い。

キーチェーンのアンロックのためのパスワードは p12 ファイルのインポート時のパスフレーズ入力欄が GUI に強制させられるのとは違い CUI で求められる。

自動化するには事前にログインパスワードも放り込む必要があるため -p で指定することになる。

# LOGIN_PASSWORD はどこかで用意しておく。
$ security unlock-keychain -p "$LOGIN_PASSWORD" "$LOGIN_KEYCHAIN"

これでキーチェーンをアンロックすることで、秘密鍵のインポートに成功するようになる。

また Jenkins の CI 環境を Mac mini で構築して運用しているようなあるあるパターンでは、コードサインの前に必ずアンロックを仕込むことになる。

macOS Sierra から導入されたチェック機構を回避するために undocumented なサブコマンドを使わなくてはならない話。

完全にこれが肝なのだが、上の方でちょっと書いた

「コードサイン用の秘密鍵を macOS の Keychain に import することまでは成功するけど、その秘密鍵を実際に使用する初回にだけ出るアラートを 『確認』 しないと、秘密鍵の使用に失敗する機構が Sierra から導入されてしまった」というような地雷がある。

この話。

昔はキーチェーンがアンロックされていて、かつ、秘密鍵を使いたいアプリケーションにアクセス許可があればコードサインは成功していたのだが、 macOS Sierra から仕様が変わりユーザの操作なく取り込んだ鍵にアクセスすると、

security wants to use your confidential information stored in …

のようなダイアログが出て失敗してしまうようになってしまった。

詳しくは iOS ビルド環境を Jenkins と Docker と Ansible でコード化する(実際のコード付き) | blog.fenrir-inc.com に書かれている。

回避するには set-key-partition-list サブコマンドを使う。

# security set-key-partition-list は Sierra 以降にしか存在しない。
if security set-key-partition-list --help 2>&1 | grep -q Usage:; then
    security set-key-partition-list -S apple-tool:,apple: -s -k "$LOGIN_PASSWORD" "$LOGIN_KEYCHAIN"
fi

ここまでやると真っ新な状態からコードサインができる状態まで持っていくことが出来る。

Provisioning Profile や秘密鍵をお手元の GitHub Enterprise あたりで権限を含め適切に管理しつつ、新しいメンバーが増えたら git clone してから ./setup.sh するだけで全てが手に入るようにしたり、秘密鍵が更新されたら git pull してから同じように ./setup.sh すれば更新されるようにしたり、 git push で該当する Jenkins Mac mini ノードが勝手に自分自身をアップデートするような仕掛けを作ってやることは難しくないはずだ。

最低でも一年に一回は秘密鍵の作り直しなどをする必要があるし、 Provisioning Profile はもっと高頻度で作り直したり増えたりすることが多い訳だし。

締め

アプリ開発は難しいし、銀の弾丸はない。

Automatically manage signing は組織の運用によっては全くハマらない場合がある。つらいことです。