約1年前に作ったAppleウォレットのパス(pkpassファイル)の中身を見るためのツールでパスの署名データを読めるようにしました。

この記事では、その実装を通してPKCS #7形式の署名データの(ASN.1で記述された)データ構造とその読み取り方について解説していきます。
- 背景: Appleウォレットの署名
- 前提・制約
- 署名データの構造
- 実装
- 感想
- 補足1 Blazor WebAssemblyでCertificatesを読み込めない理由
- 補足2 openssl_pkcs7_sign()とopenssl_cms_sign()に差異はあるのか
- 関連リンク
背景: Appleウォレットの署名
Appleウォレットのパス(チケットなど)の実体は拡張子がpkpassになっているだけのただのzipファイルなのですが、そのzipには署名ファイル(signature)が含まれています。
署名はAppleウォレット用の証明書を使って作成されており、この署名によって
- パスが改ざんされていないこと
- Apple Developerに登録されたアカウントによって作成されたパスであること
を検証可能にしています。
Appleウォレットのパスの署名はPKCS #7の分離(detached)形式と定められており、例えばopensslコマンドであれば以下のようなコマンドを叩けば中身を確認可能です。
例)
> openssl pkcs7 -in signature -inform DER -print_certs -text -noout
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
(省略)
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN=Apple Worldwide Developer Relations Certification Authority, OU=G4, O=Apple Inc., C=US
Validity
Not Before: Nov 17 13:21:26 2025 GMT
Not After : Dec 17 13:21:25 2026 GMT
Subject: UID=pass.com.example.muno92, CN=Pass Type ID: pass.com.example.muno92, OU=XXXXX, O=XXXXX, C=JP
(省略)
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
(省略)
Signature Algorithm: sha256WithRSAEncryption
Issuer: C=US, O=Apple Inc., OU=Apple Certification Authority, CN=Apple Root CA
Validity
Not Before: Dec 16 19:36:04 2020 GMT
Not After : Dec 10 00:00:00 2030 GMT
Subject: CN=Apple Worldwide Developer Relations Certification Authority, OU=G4, O=Apple Inc., C=US
(省略)
1件目の証明書がAppleウォレットの生成に使われたアカウントの証明書、2件目の証明書がAppleの中間証明書です。
証明書は1年に1回更新が必要で、更新するたびにこの方法でValidityが更新されているか確認するようにしていました。
ただ、それをするにはpkpassファイルからsignatureを取り出し、それに対してopensslコマンドを実行しなければならず若干手間・・・
今回、前々から付けたいと思っていたsignatureの読み取り機能を追加しました。
前提・制約
このツールはC#のコードをWebAsssemblyとして実行できるようにするBlazor WebAssemblyで実装しています。
以降、この記事では
- Microsoft.AspNetCore.Components.WebAssembly: バージョン10.0.1
を前提とします。
C#でPKCS #7形式の署名ファイルは標準ライブラリで読み取れます。
using System.Security.Cryptography.Pkcs; var cms = new SignedCms(); cms.Decode(File.ReadAllBytes("signature")); var issuerCertificate = cms.Certificates.Single(c => c.Issuer.Contains("CN=Apple Worldwide Developer Relations Certification Authority")); var subject = issuerCertificate.Subject; var start = issuerCertificate.NotBefore; var end = issuerCertificate.NotAfter;
しかし、このコードをBlazor WebAssemblyで動かすとCertificatesにアクセスした時点でPlatformNotSupportedExceptionが発生してしまいます。
System.Security.Cryptographyは証明書の読み取りにOS固有の機能を使っており、Blazor WebAssemblyをサポートしていないようです。
(話が逸れるのでここではこれくらいの説明に留めます。詳しく知りたい方は最後に補足を書いているのでそちらをご覧ください)
System.Security.Cryptographyが使えないならどうするか考えた時に
などいくつかの選択肢が浮かびました。
(ちなみに、「Blazor Serverを使う」のは端から候補外でした。上述の記事に書いたようにサーバーにはデータを送信しないツールにしたかったので)
どうしようかなと考えていた時に「関連するRFCなどを読めば独自実装できるのでは・・・?」と思い、やりたくなったので学習も兼ねて実装してみる事にしました。
(ライブラリや言語を変えるだけだとそれらの使い方を知るだけで署名ファイルについてはブラックボックスのままだな、と思ったのもあります。WebAssemblyの学習目的であれば別の言語のWASMを使ってみるのもありだったとは思いますが)
署名データの構造
PKCS #7はRFC 2315、またPKCS #7と互換性を持った新しい規格のCMS(Cryptographic Message Syntax)はRFC 5652で定義されています。
さて署名データの構造を把握してみようとRFC 2315を読み始めてみたのですが、
SignedData ::= SEQUENCE {
version Version,
digestAlgorithms DigestAlgorithmIdentifiers,
contentInfo ContentInfo,
certificates
[0] IMPLICIT ExtendedCertificatesAndCertificates
OPTIONAL,
crls
[1] IMPLICIT CertificateRevocationLists OPTIONAL,
signerInfos SignerInfos }
のようなデータ型が定義されているだけでファイルをどう読み取ったら良いのかこれだけではわかりません。
GeminiやClaudeに色々聞いてみて知ったのですが、署名ファイルやX.509の証明書などは
- データ構造 (ASN.1という言語で定義)
- ファイル構造 (BER/DER/PEM)
と2段階に分かれた構造になっています。
基礎となっているASN.1、エンコード形式 (BER/DER/PEMなど)
ASN.1は抽象的なデータ形式で、同じASN.1で定義されていればそれがなんであれDERやPEMなどの形式でエンコード・デコードできます。
Let's Encryptの解説記事がとても分かりやすく、参考にさせて頂きました。
SEQUENCEはJSONで例えるならObjectに相当し、SignedDataは先頭にVersion型のversion、その次にDigestAlgorithmIdentifiers型(OID)のdigestAlgorithms、とデータが格納されていきます。
それをエンコードする方法としてBER/DER/PEMなどがあります。
DERやPEMは目にした事がある方も多いのではないでしょうか。
BERはBasic Encoding Rulesの略で「型 長さ 値」の順でバイトを並べていきます。
ASN.1 JavaScript decoderで署名ファイルをパースさせてみると、実際に「型 長さ 値」のバイトが連なっているのがよく分かります。

「型 長さ 値」の定義から分かるように、2つ目の「長さ」を読み取った時点でvalueの長さは分かります。
しかし、BERでは可変長も許されており、「終端の0二つまで読む」不定長も許可されています。
BERを拡張し、
- 固定長のみに限定
- 正規化
した形式がDER (Distinguished EncodingRules)、そのDERをbase64エンコードしてメールでも扱えるようにした形式がPEM (Privacy-Enhanced Mail)です。
ASN.1とエンコード形式、これらの基盤の上にPKCS #7は定義されています。
PKCS #7
PKCS #7では6つの型が定義されています。
- data
- 任意のバイト列を表現
- signedData
- デジタル署名に使用
- Appleウォレットの署名データはこの形式
- envelopedData
- 暗号化に使用
- signedAndEnvelopedData
- デジタル署名・暗号化に使用
- ※新しいCMSでは存在せず
- signedDataとenvelopedDataの入れ子で実現可能であり、形式に問題もあるため
- 詳しくはRFC 2315 Section 11のNote参照
- digestedData
- コンテンツとそのハッシュを保持する
- encryptedData
- 暗号化に使用
- envelopedDataと異なり、鍵は保持しない (別の手段で管理することを想定)
それぞれの型はContentType (OID)で識別可能です。
これでPKCS #7の前提となる仕様は簡単に押さえられたので、ここからは実装について説明していきます。
実装
C#にはSystem.Formats.Asn1.AsnReaderクラスがあり、BER/CER/DER形式であればエンコード形式を気にせずにASN.1形式のデータを読み込めます。
JsonSerializerでJSONをパースする場合のような最初に期待する型を渡す形ではなく、ASN.1のバイト列を読み込んで先頭から1つ1つ読み込んでいく形での実装となります。
SignedDataなどの6つの型は下記のcontentに当たるため、まずそこまで読み進んでいく必要があります。
ContentInfo ::= SEQUENCE {
contentType ContentType,
content
[0] EXPLICIT ANY DEFINED BY contentType OPTIONAL }
これをAsnReaderを使って実装すると以下のようになります。
var derReader = new AsnReader(バイト列, AsnEncodingRules.DER); // バイト列から一番外側のSequenceを読み込む var contentInfo = derReader.ReadSequence(); // その内部を更に1つ1つ読み進む var contentType = contentInfo.ReadObjectIdentifier(); var content = contentInfo.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
ASN.1の型にあったReadXXXを呼び出す事で値を取得し、読み込みを1つ先に進められます。
仮に不要な値があったとしても、その更に先に進むためにはReadする必要があります。
さて、上記のコードでReadSequenceに引数を渡している場合と渡していない場合がありますよね?
これはASN.1でcontentに0番のタグが振られているからなのですが、こういった独自のタグはOPTIONALな値があった時にその項目を区別可能にする目的で使われているようです。
ContentInfoだと独自タグの項目は1つしかありませんが、SignedDataの定義を見ると分かりやすいです。
SignedData ::= SEQUENCE {
version Version,
digestAlgorithms DigestAlgorithmIdentifiers,
contentInfo ContentInfo,
certificates
[0] IMPLICIT ExtendedCertificatesAndCertificates
OPTIONAL,
crls
[1] IMPLICIT CertificateRevocationLists OPTIONAL,
signerInfos SignerInfos }
certificatesは署名に使われた証明書のリスト、crlsは証明書失効リストです。
それぞれ型を深堀りすると同じSET OF(エンコード時のソートが義務付けられた配列)になっており、そのままではどちらか片方だけあった時に証明書リストなのか証明書失効リストなのか区別がつかなくなってしまいます。
そんな場合に独自タグがあることで区別がつくようになっています。
独自タグがある項目をデフォルトのSequenceタグで呼ぶとタグ番号が食い違ってしまうため、読み込む際は引数にタグの番号を渡して呼び出す必要があります。
このように、定義に沿って1つ1つ地道に実装していきました。
opensslコマンドにはASN.1の構造を出力できるコマンドがあり、RFC上の定義と実データの構造を照らし合わせるのに役に立ちました。
> openssl asn1parse -in signature -inform DER -i
0:d=0 hl=4 l=3405 cons: SEQUENCE
4:d=1 hl=2 l= 9 prim: OBJECT :pkcs7-signedData
15:d=1 hl=4 l=3390 cons: cont [ 0 ]
19:d=2 hl=4 l=3386 cons: SEQUENCE
23:d=3 hl=2 l= 1 prim: INTEGER :01
26:d=3 hl=2 l= 15 cons: SET
28:d=4 hl=2 l= 13 cons: SEQUENCE
30:d=5 hl=2 l= 9 prim: OBJECT :sha256
(省略)
> openssl cms -cmsout -print -in signature -inform DER
CMS_ContentInfo:
contentType: pkcs7-signedData (1.2.840.113549.1.7.2)
d.signedData:
version: 1
digestAlgorithms:
algorithm: sha256 (2.16.840.1.101.3.4.2.1)
parameter: NULL
(省略)
実装上の注意点・引っ掛かりがちな所
型を間違える
ASN.1は型ごとにタグがあり、間違った型として読み込もうとするとエラーになります。
個人的には、数階層ネストしている項目だと型を見誤りやすかったです。
その場合
The provided data is tagged with 'Universal' class value '16', but it should have been 'Universal' class value '6'.
のようなエラーが出力され、タグ番号から期待する型と実際の型を判別できるので落ち着いてエラーメッセージを見れば十分対応できると思います。
違うエンコード形式は読み込めない
AsnReaderは最初にインスタンスを生成する時にエンコード形式を指定する必要があり、そのReaderが読み込むエンコード形式が固定されます。
そのため、例えばDER用のAsnReaderでBER形式の署名データを読み込んだ場合、長さの計算がどこかで食い違ってエラーになってしまいます。
AsnReaderにはエンコード形式を判別するメソッドは無さそうなので、複数のエンコード形式が入力されてくる可能性がある場合は
var derReader = new AsnReader(signature, AsnEncodingRules.DER); try { return derReader.ReadSequence(); } catch (AsnContentException) { var berReader = new AsnReader(signature, AsnEncodingRules.BER); try { return berReader.ReadSequence(); } catch (AsnContentException e) { // エラー処理 } }
のようにそれぞれのエンコード形式を試すことになるかなと思います。
文字列は複数の型がある
ASN.1ではUTF8String・PrintableStringなど複数の文字列型があり、最終的にC#として読み込むのが同じstring型であってもASN.1の型に合わせて読み込まないとエラーになってしまいます。
しかし、項目によっては文字列の型が事前に判別できない・同じ項目でも型が変わり得ることがあります。
実際、証明書のIssuerのorganizationNameが同じ「Apple Inc.」なのにUTF8STRINGになっている場合とPRINTABLESTRINGになっている場合がありました。
そのため、
private static string ReadString(AsnReader stringSequence) { return stringSequence.PeekTag().TagValue switch { (int)UniversalTagNumber.PrintableString => stringSequence.ReadCharacterString(UniversalTagNumber .PrintableString), (int)UniversalTagNumber.UTF8String => stringSequence.ReadCharacterString(UniversalTagNumber.UTF8String), _ => "", }; }
のようにタグを読み取ってそれに応じて読み取りメソッドを変化させるラッパーメソッドを作りました。
(今のところ遭遇したのはUTF8String・PrintableStringなので必要最小限の実装にしています)
感想
このような点を気にしながら実装を進め、最終形は以下のようになっています。
AsnReaderがエンコード形式を吸収してくれたので想像していたより簡単に実装できました。
署名の読み取り、抽象度が高い順に並べると
- System.Security.Cryptographyを使いASN.1もエンコード形式もほとんど気にせず読み取る
- AsnReaderを使い、ASN.1の読み取りだけ行う
- エンコードから完全独自実装する
って感じで、ASN.1の読み取りとデコード両方を一気にやらずに済み、程よい抽象度だったかなと思います。
また、手元に持っている各社のpkpassファイルで動作確認していたら1つだけBER形式でエンコードされたパスがあったのですが、AsnReaderのお陰で簡単に対応できました。
仮に自分でDERデコードを実装していたら心が折れていたと思います・・w
いつかDERやBERのデコードにも挑戦してみたいです。
また、ASN.1のデータ形式を見た時にBNFに似ているなと感じ(実際ASN.1の定義にはABNFが使われているようなのですが)、BNFには去年興味を持ち始めたので個人的に繋がった感がありました。
ASN.1自体は他にも色々使われているデータ形式で、今後応用が効きそうなのも面白そうだなあと思っています。
以上、長文になりましたがお読み頂きありがとうございました。
補足1 Blazor WebAssemblyでCertificatesを読み込めない理由
.NETはクロスプラットフォームなランタイムではあるのですが、暗号周りはOSの機能を使っています。
.NET での X.509 証明書のサポートの大部分は、OS ライブラリから提供されます。 証明書を .NET の X509Certificate2 または X509Certificate インスタンスに読み込むには、証明書が、基盤となる OS ライブラリによって読み込まれる必要があります。
実際、SignedCmsのCertificatesにアクセスするとX509CertificateLoader.LoadCertificateが呼び出され、その呼び出し先として各ランタイム用の実装がありました。
- https://github.com/dotnet/runtime/blob/v10.0.1/src/libraries/System.Security.Cryptography.Pkcs/src/System/Security/Cryptography/Pkcs/SignedCms.cs#L95
- https://github.com/dotnet/runtime/blob/v10.0.1/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509CertificateLoader.netcore.cs#L18
- https://github.com/dotnet/runtime/blob/v10.0.1/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509CertificateLoader.iOS.cs#L13
- https://github.com/dotnet/runtime/blob/v10.0.1/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509CertificateLoader.Android.cs#L12
- https://github.com/dotnet/runtime/blob/v10.0.1/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509CertificateLoader.macOS.cs#L16
- https://github.com/dotnet/runtime/blob/v10.0.1/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509CertificateLoader.OpenSsl.cs#L12
- https://github.com/dotnet/runtime/blob/v10.0.1/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509CertificateLoader.NotSupported.cs#L8
- https://github.com/dotnet/runtime/blob/v10.0.1/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509CertificateLoader.Windows.cs#L13
ビルド時に読み込む実装を切り替えているようで、Blazor WebAssemblyだとX509CertificateLoader.NotSupported.csが使われるようです。
(このファイル、他のOSでもAPIによってはNotSupportの文字がちらほら見え、OSの違いがうっすら感じられて面白いです)
補足2 openssl_pkcs7_sign()とopenssl_cms_sign()に差異はあるのか
この時の記事に
openssl_pkcs7_sign関数ではバイナリ形式で直接ファイルに署名を書き出すオプションがありません。
(中略)なので一旦multipart形式で書き出し、(中略)署名ファイルから雑に「Base64エンコードされた署名」を抜き出してBase64デコードしています。
openssl_cms_sign関数だと直接バイナリ形式で署名を書き出せる事に気づき、openssl_cms_signで生成した署名ファイルを含めたパスも手元の環境では開けました。
CMSはPKCS#7を拡張した形式のようなのでそっちでも問題なくパスとして認識されるような気もするのですが、Appleのドキュメントに書かれていない形式で署名ファイルを作成して動かなくなっても嫌だな、とそのままにしています。
と書いていたのですが、今回少しは署名について理解が深まったのでopenssl asn1parseの結果を比較してみました。
比較対象は
です。
すると、openssl_pkcs7_sign()関数だけが証明書・中間証明書の順番が逆になっていた(「署名に使った証明書→中間証明書」の順)位で大きな差異はありませんでした。
原因はまだ確認できていませんが、RFCを見比べてみてもPKCS #7とCMSは (バージョンを揃えている限り) ほぼ違いはなさそうなのでどちらでも問題なさそうです。
内部構造が分かってくると中身を覗いてみて検証できるのが面白いですね。
関連リンク
- RFC
- 署名自体の定義
- 証明書の定義
- IssuerやSubjectに使われるcommonName (cn)などのAttributeの定義
- BER/CER/DERの定義
- ASN.1 と DER へようこそ
- ASN.1 JavaScript decoder