はじめに
sigstoreのKeyless SigningをCosignコマンドを使わずに手動で頑張るシリーズの三本目です。誰にも望まれてないけどやりきります。
- curlでKeyless Signingする (1) - OpenID Connect編
- curlでKeyless Signingする (2) - Fulcio編
- curlでKeyless Signingする (3) - Signed Certificate Timestamp編(本記事)
- curlでKeyless Signingする (4) - Rekor編
- curlでKeyless Signingする (5) - Verify編
- curlでKeyless Signingする (6) - Trillian編
前回はFulcioで証明書の発行までを行いました。Fulcioから返された署名の中にSigned Certificate Timestamp (SCT) が含まれているので、今回はそれの検証をしてみます。シュッと終わらせるつもりがドチャクソハマったので1つの記事にしました。一つだけ言っておきたいのは手動でCLIで検証するとかわけのわからないことを言ってないでおとなしくプログラムを書いたほうが良いということです。
あとKeylessとか言ってますがもはや関係なくてただのSCT検証になっています。ただCosignのコードを読んで挙動を理解したものを書いているので、一般的なSCTとは少し異なる可能性があります。
Cosignのコードと言いましたが、Cosignは内部で以下のライブラリを利用しており大半の処理はこのライブラリから来ています。
CosignにおけるSCT検証は以下の関数なのでこの中を追っていけば良いです。
cosign/verify.go at 31e665415f2da47356e4657e751443fcf5f394ed · sigstore/cosign · GitHub
SCTとは?
Signed Certificate Timestamp (SCT)とはCertificate Transparency (CT) で登場する用語で、CTログサーバに証明書を登録したときに返却されます。登録した日時に署名をしたもので、認証局はこのSCTをさらに証明書に埋め込んでクライアントに返します。
CTについては以前少しブログを書きました。 knqyf263.hatenablog.com
CTそのものについての基本的な内容は以下のスライドが分かりやすいです。
https://ozuma.sakura.ne.jp/sumida/wp-content/uploads/2015/04/01_cert_t-view.pdf
そしてSCTの細かい話は以下に書かれています。 ozuma.hatenablog.jp
sigstoreにおけるSCT
FulcioにおけるCertificate Transparency Logの仕組みは以下に書いてあります。前回の記事で説明したとおりです。
一応もう一度簡単に説明しておくと、FulcioがX.509の拡張領域に毒を入れてPrecertificateを作成し、それをCTログサーバにアップロードします。そしてCTログサーバが証明書を受け取った時刻に署名してSCTを返します。この辺は一般的なCTの仕組みと同じだと思います。
そして得たSCTを証明書に埋め込み、Fulcioで再度署名を行います。
なぜPrecertificatを用意してまで2回も署名するの?という点については上記の参考ブログに書いてありますが、鶏が先か卵が先かというやつです。証明書にSCTを埋め込みたいけど証明書がなければSCTが作れないので、仕方なくCT用の証明書としてPrecertificateを作ってるということです。CTのこの仕組み自体がイケてないよね、という話はあると思いますが一旦置いておきます。
SCTの確認
流れを理解したので、前回の記事で取得した証明書を再度確認します。
$ openssl x509 -text -in cert.pem Certificate: Data: Version: 3 (0x2) Serial Number: 57:bb:87:ed:6f:23:30:a4:7b:56:48:b8:e6:cf:ae:20:d4:44:f4:da Signature Algorithm: ecdsa-with-SHA384 Issuer: O = sigstore.dev, CN = sigstore-intermediate Validity Not Before: Sep 20 07:58:11 2022 GMT Not After : Sep 20 08:08:11 2022 GMT Subject: Subject Public Key Info: Public Key Algorithm: id-ecPublicKey Public-Key: (256 bit) pub: 04:ff:92:f6:52:7f:e2:6c:b9:43:09:44:ac:bb:cd: 3f:cb:3a:13:a6:cc:b4:ad:16:9a:05:33:7f:90:fc: 9e:99:51:28:ea:36:ac:54:15:49:b4:68:9e:63:8d: 64:0e:ee:e8:38:38:84:9f:48:06:eb:d3:7b:0a:2d: 89:2e:24:09:25 ASN1 OID: prime256v1 NIST CURVE: P-256 X509v3 extensions: X509v3 Key Usage: critical Digital Signature X509v3 Extended Key Usage: Code Signing X509v3 Subject Key Identifier: 21:96:4C:19:04:59:56:D6:E3:B7:43:5D:AF:4D:63:97:26:1B:89:F8 X509v3 Authority Key Identifier: DF:D3:E9:CF:56:24:11:96:F9:A8:D8:E9:28:55:A2:C6:2E:18:64:3F X509v3 Subject Alternative Name: critical email:knqyf263@gmail.com 1.3.6.1.4.1.57264.1.1: https://github.com/login/oauth CT Precertificate SCTs: Signed Certificate Timestamp: Version : v1 (0x0) Log ID : 08:60:92:F0:28:52:FF:68:45:D1:D1:6B:27:84:9C:45: 67:18:AC:16:3D:C3:38:D2:6D:E6:BC:22:06:36:6F:72 Timestamp : Sep 20 07:58:11.540 2022 GMT Extensions: none Signature : ecdsa-with-SHA256 30:45:02:20:65:9B:88:5C:13:9E:0E:FC:CE:7F:78:D8: 77:CE:E2:C9:DC:34:A6:3C:8C:B3:A1:68:74:40:53:24: DD:35:43:6C:02:21:00:C1:4D:BB:E1:7D:43:76:DC:C7: 20:B2:58:D9:87:FC:EE:C3:74:A1:0E:DE:40:CA:8A:A9: 67:58:94:0A:6A:C1:91 Signature Algorithm: ecdsa-with-SHA384 Signature Value: 30:65:02:30:30:de:6d:9c:10:ed:57:75:00:5a:2e:a7:55:e0: 53:df:1d:a3:e7:b6:93:79:3d:23:ed:2c:89:ae:d5:49:07:34: eb:91:e5:af:67:36:97:f2:bb:37:78:92:80:8c:06:64:02:31: 00:fb:09:54:f0:46:81:1b:3d:1b:ee:c0:ab:26:13:d5:a3:b2: d6:3e:4b:4e:5f:2a:99:52:37:b8:ef:3e:d0:86:40:89:38:0f: b6:ba:6a:8f:60:49:27:f9:12:9d:22:ab:1a
CT Precertificate SCTs
の箇所にSCTの詳細が書かれています。肝心のTimestampに加え、Log IDやSignatureが入っています。
CTログサーバの公開鍵取得
TUFという仕組みでCTログサーバの公開鍵を取得しています。TUFは未だに理解できていないですが、とりあえず安全に最新の公開鍵を取得可能なようです。いつか余裕があればちゃんと調べたいです。以下に公開鍵などの一覧があります。
https://sigstore-tuf-root.storage.googleapis.com/
このうち、ctfe.pubが目的の鍵なのでこれをダウンロードします。
$ curl -O https://sigstore-tuf-root.storage.googleapis.com/targets/ctfe.pub
Log IDの検証
上でLog IDというものがありましたが、今落としてきた公開鍵がこのLog IDと一致することを最初に確認します。CTにおけるLog IDは単にCTログサーバの公開鍵をDER形式にしてSHA256のハッシュを取るだけのようです。以下に詳しく書かれています。
落としてきた ctfe.pub
を使って上記のLog IDと一致することを確認します。
$ openssl ec -outform der -pubin -in ctfe.pub | openssl dgst -SHA256 read EC key writing EC key SHA2-256(stdin)= 086092f02852ff6845d1d16b27849c456718ac163dc338d26de6bc2206366f72
上の証明書内のLog IDは 08:60:92...:6F:72
なので確かに一致しました。
図にするまでもないですが一応。
署名検証
では次にこの公開鍵を使ってSCTの署名を検証するのですが、ここが全然うまく行かなくて時間がかかりました。ググっても見つからなかったのでCLIでSCT検証をしたの世界初なんじゃないでしょうか。SCTが証明書に埋め込まれていない場合のやり方は以下にあるので参考になりますが、今回は上記の通り埋め込まれているのでこのやり方ではうまく行かないです。
証明書に埋め込まれているSCTに対して署名検証ができるのかと思いきや、自分で署名対象を組み立てる必要がある点が難しいです。
上図のように複数の要素によって構成された値から署名は生成されています。以下で手でやってみます。
SCTのデータフォーマット
Signed Certificate TimestampのフォーマットはRFC 6962の3.2で定義されています。
RFC 6962: Certificate Transparency
まず事前に関連する型の定義です。この型達は後半で署名元を組み立てるときに出てきます。
enum { x509_entry(0), precert_entry(1), (65535) } LogEntryType; enum { certificate_timestamp(0), tree_hash(1), (255) } SignatureType; enum { v1(0), (255) } Version; struct { opaque key_id[32]; } LogID; opaque TBSCertificate<1..2^24-1>; struct { opaque issuer_key_hash[32]; TBSCertificate tbs_certificate; } PreCert; opaque CtExtensions<0..2^16-1>;
そしてSCTの定義は以下です。
struct { Version sct_version; LogID id; uint64 timestamp; CtExtensions extensions; digitally-signed struct { Version sct_version; SignatureType signature_type = certificate_timestamp; uint64 timestamp; LogEntryType entry_type; select(entry_type) { case x509_entry: ASN.1Cert; case precert_entry: PreCert; } signed_entry; CtExtensions extensions; }; } SignedCertificateTimestamp;
型情報を無視すると上から順に
- sct_version
- id
- timestamp
- extensions
- digitally-signed
の5つがSCTには含まれるということが分かります。再度上の証明書内のSCTのフィールドを見てみます。
CT Precertificate SCTs: Signed Certificate Timestamp: Version : v1 (0x0) Log ID : 08:60:92:F0:28:52:FF:68:45:D1:D1:6B:27:84:9C:45: 67:18:AC:16:3D:C3:38:D2:6D:E6:BC:22:06:36:6F:72 Timestamp : Sep 20 07:58:11.540 2022 GMT Extensions: none Signature : ecdsa-with-SHA256 30:45:02:20:65:9B:88:5C:13:9E:0E:FC:CE:7F:78:D8: ...
確かに
- Version
- Log ID
- Timestamp
- Extensions
- Signature
の5つが含まれています。
この digitally-signed
の部分は署名の値だけが証明書に含まれています。つまり署名した対象は含まれていないので署名元は自分で組み立てる必要があります。
digitally-signed struct { Version sct_version; SignatureType signature_type = certificate_timestamp; uint64 timestamp; LogEntryType entry_type; select(entry_type) { case x509_entry: ASN.1Cert; case precert_entry: PreCert; } signed_entry; CtExtensions extensions; };
各フィールドの値を入手したとしてどうやってエンコードするのか疑問かもしれませんが、エンコード方法はRFC5246に書いてあります。
RFC 5246: The Transport Layer Security (TLS) Protocol Version 1.2
以下の5つの値を入手すれば署名元を作れます。
- sct_version
- signature_type
- entry_type
- signed_entry
- extensions
それぞれ見ていきます。
sct_version
ここは v1
なので0です。
enum { v1(0), (255) } Version;
signature_type
ここは certificate_timestamp
なので0です。
enum { certificate_timestamp(0), tree_hash(1), (255) } SignatureType;
timestamp
タイムスタンプはミリ秒まで含んだepoch timeです。
"timestamp" is the current NTP Time [RFC5905], measured since the epoch (January 1, 1970, 00:00), ignoring leap seconds, in milliseconds.
今回は Sep 20 07:58:11.540 2022 GMT
なのでまずepochに直します。
$ date -d 'Sep 20 07:58:11.540 2022 GMT' +%s%3N 1663660691540
そしてこれを16進数に直します。
$ printf "%x\n" 1663660691540 18359e79054
この数値をuint64として扱えばOKです。
entry_type
LogEntryTypeですが、2種類の値が選択可能です。
enum { x509_entry(0), precert_entry(1), (65535) } LogEntryType;
今回はPrecertificateに対してSCTを作っているので precert_entry(1)
になります。ここで x509_entry(0)
にしていたせいで検証が通らず無限にハマりました。 x509_entry(0)
はTLS extensionやOCSP StaplingなどSCTを埋め込まない場合に使われるのかと想像していますが、そこまではちゃんと調べてないです。
signed_entry
上で選択した entry_type
に応じて型が変わります。今回は precert_entry(1)
なので PreCert
になります。
select(entry_type) { case x509_entry: ASN.1Cert; case precert_entry: PreCert; } signed_entry;
PreCertは以下のように定義されています。
struct { opaque issuer_key_hash[32]; BSCertificate tbs_certificate; } PreCert;
issuer_key_hash
はSCTを生成時に使った証明書の公開鍵のSHA256ハッシュ値になります。もう少し厳密には証明書内のSubjectPublicKeyInfoの部分を取り出してDERエンコードしたものをハッシュします。前回Fulcioにリクエストを送ったときに一緒に返ってきていました。ルート証明書ではなく中間証明書になります。
この証明書はサーバから取ってこれるので今回は再取得します。証明書は複数ありえますし失効によって異なる証明書が返ることもあるはずなので、実際にはFulcioから返ってきたものを使うべきです。
$ curl -O https://sigstore-tuf-root.storage.googleapis.com/targets/fulcio_intermediate_v1.crt.pem
この中間証明書の中身を見てみます。
$ openssl x509 -text -in fulcio_intermediate_v1.crt.pem -pubin -noout Certificate: Data: Version: 3 (0x2) Serial Number: b9:d5:89:57:e7:53:46:eb:25:ab:26:46:41:eb:9f:f5:27:7d:a4 Signature Algorithm: ecdsa-with-SHA384 Issuer: O = sigstore.dev, CN = sigstore Validity Not Before: Apr 13 20:06:15 2022 GMT Not After : Oct 5 13:56:58 2031 GMT Subject: O = sigstore.dev, CN = sigstore-intermediate Subject Public Key Info: Public Key Algorithm: id-ecPublicKey Public-Key: (384 bit) pub: 04:f1:15:52:ff:2b:07:f8:d3:af:b8:36:72:3c:86: 6d:8a:58:14:17:d3:65:6a:b6:29:01:df:47:3f:5b: c1:04:7d:54:e4:25:7b:ec:b4:92:ee:cd:19:88:7e: 27:13:b1:ef:ee:9b:52:e8:bb:ef:47:f4:93:93:bf: 7c:2d:58:0c:cc:b9:49:e0:77:88:7c:5d:ed:1d:26: 9e:c4:b7:18:a5:20:12:af:59:12:d0:df:d1:80:12: 73:ff:d8:d6:0a:25:e7 ASN1 OID: secp384r1 NIST CURVE: P-384 X509v3 extensions: X509v3 Key Usage: critical Certificate Sign, CRL Sign X509v3 Extended Key Usage: Code Signing X509v3 Basic Constraints: critical CA:TRUE, pathlen:0 X509v3 Subject Key Identifier: DF:D3:E9:CF:56:24:11:96:F9:A8:D8:E9:28:55:A2:C6:2E:18:64:3F X509v3 Authority Key Identifier: 58:C0:1E:5F:91:45:A5:66:A9:7A:CC:90:A1:93:22:D0:2A:C5:C5:FA Signature Algorithm: ecdsa-with-SHA384 Signature Value: 30:64:02:30:3c:2b:10:2b:80:d8:89:96:03:3c:86:83:8b:91: c5:2a:77:f1:5f:1e:80:49:25:66:11:17:ec:ca:76:01:89:7d: 97:e9:22:51:9d:95:3c:e3:ff:43:65:d9:c5:be:fc:66:02:30: 4e:b7:a4:29:06:57:38:27:fd:03:c6:f9:13:0a:aa:5c:96:fc: e2:2f:a0:42:08:f9:e3:76:52:01:dc:fb:b7:07:1b:0f:9b:28: 14:63:b2:22:db:36:dd:09:d9:62:8a:8c
CN = sigstore-intermediate
になっており中間証明書感があります。
このうち Subject Public Key Info
だけ必要なので取り出します。
$ openssl x509 -in fulcio_intermediate_v1.crt.pem -pubin -pubkey -noout > fulcio_intermediate_v1.pub.pem
このままだとPEMになっているのでDERに変換します。上のコマンドでDERの変換まで一発でやる方法は見つけられなかったので、再度opensslコマンドでPEMからDERに変換します。
$ openssl ec -in fulcio_intermediate_v1.pub.pem -pubin -outform DER > fulcio_intermediate_v1.pub.der read EC key writing EC key
これでDER形式の公開鍵が手に入りました。これのSHA256ハッシュ値はあとで計算します。該当するコードは以下です。RawSubjectPublicKeyInfoを単にSHA256でハッシュ計算しているのが分かります。
tbs_certificate
こいつも結構厄介です。まずここにはDER形式のPrecertificateが入ります。ただし、Precertificateのために入れたpoison extensionと署名は除きます。
tbs_certificate" is the DER-encoded TBSCertificate (see [RFC5280]) component of the Precertificate -- that is, without the signature and the poison extension
さらにfinal certificateからSCTを消してもこの tbs_certificate
が得られるとも書いています。多分署名も取り除く必要があると思うのですが、当たり前過ぎて書かれていないのかもしれません。
Note that it is also possible to reconstruct this TBSCertificate from the final certificate by extracting the TBSCertificate from it and deleting the SCT extension.
最終的にクライアントに返される証明書は既に持っているので、ここからSCTを消せば良さそうです。
残念なことにCLIでやる方法は見つけられなかったのでPythonのコードを書きました。CLIだけで完遂できず悔しいです。
from pyasn1.codec.der.decoder import decode as asn1_decode from pyasn1.codec.der.encoder import encode as asn1_encode from pyasn1_modules import rfc5280 from cryptography import x509 from cryptography.hazmat.backends import default_backend with open("cert.pem", "rb") as f: cert = asn1_decode(x509.load_pem_x509_certificate(f.read(), default_backend()).tbs_certificate_bytes, asn1Spec=rfc5280.TBSCertificate())[0] newExts = [ext for ext in cert["extensions"] if str(ext["extnID"]) != "1.3.6.1.4.1.11129.2.4.2"] cert["extensions"].clear() cert["extensions"].extend(newExts) with open("cert-no-sct.der", "wb") as f: f.write(asn1_encode(cert))
Fulcioに発行してもらった cert.pem
からSCTを削除したものを cert-no-sct.der
に保存しています。 google/certificate-transparency-go
のコードを読むと確かにSCTを削除しています。
また、この tbs_certificate
は可変長であるため後ほどサイズが必要になります。
$ wc -c cert-no-sct.der 412 cert-no-sct.der $ printf "%x\n" 412 19c
この 0x019C
(=412 bytes) はあとで使います。
extensions
extensions" are future extensions to this protocol version (v1). Currently, no extensions are specified.
とのことなので今は0を埋めておけばよいです。
SCT署名対象の作成
ようやく全ての値を入手したので digitally-signed
の元を作っていきます。エンコード方法はRFC5280に書いてありますが、以下の関数を見ると具体的に理解できると思います。
基本は上のフィールドから順番に固定長でビッグエンディアンで詰めていって、可変長なフィールドだけ最初に長さを入れるという簡単なエンコーディングです。
ということで手動で作ってみます。
# sct_version = v1 = 0x00, 1 byte echo -n -e \\x00 > data.bin # signature_type = certificate_timestamp = 0x00, 1 byte echo -n -e \\x00 >> data.bin # timestamp = 0x18359e79054, 8 bytes echo -n -e \\x00\\x00\\x01\\x83\\x59\\xe7\\x90\\x54 >> data.bin # entry_type = precert_entry = 0x0001, 2 bytes echo -n -e \\x00\\x01 >> data.bin # issuer_key_has, 32 bytes cat fulcio_intermediate_v1.pub.der | openssl dgst -sha256 -binary >> data.bin # length of DER-encoded cert, 3 bytes echo -n -e \\x00\\x01\\x9c >> data.bin # DER-encoded cert without the SCT extension cat cert-no-sct.der >> data.bin # extensions, none, 0-length, 2 bytes echo -n -e \\x00\\x00 >> data.bin
これで data.bin
には署名元の値が入っています。あとは署名検証するだけです。簡単ですね。
というのは嘘で、これを組み立てるの非常に大変でした。
SCTの検証
検証のためにSCTの署名を取り出す必要があります。opensslコマンドでSCTの署名だけ綺麗に取り出す方法が分からなかったのでgrepでゴリ押しで行きます。
$ openssl x509 -in cert.pem -pubin -outform PEM -noout -text | grep -A5 "Signature : ecdsa-with-SHA256" | grep -v "Signature" | tr -d ":\n " 30450220659B885C139E0EFCCE7F78D877CEE2C9DC34A63C8CB3A16874405324DD35436C022100C14DBBE17D4376DCC720B258D987FCEEC374A10EDE40CA8AA96758940A6AC191
このように署名だけ抜き出したあと、xxdを使って16進数で sct.sig
にダンプしておきます。
$ echo -n 30450220659B885C139E0EFCCE7F78D877CEE2C9DC34A63C8CB3A16874405324DD35436C022100C14DBBE17D4376DCC720B258D987FCEEC374A10EDE40CA8AA96758940A6AC191 | xxd -r -p > sct.sig
では晴れて検証可能になったのでopensslコマンドで検証します。
$ openssl dgst -sha256 -verify ctfe.pub -signature sct.sig data.bin Verified OK
CTログサーバの公開鍵(ctfe.pub
)を使って、SCTに含まれていたタイムスタンプを元に作った data.bin
の署名がSCT内の署名(sct.sig
)と一致することを確かめ、無事にOKとなりました。
まとめ
手動でSCTの検証をしました。これが出来たから何なんだという感じなんですが、すぐ出来るだろうと始めた手前、途中から意地になって絶対に成功させてやるみたいになってしまいました。正直時間を無駄にした感じが否めませんが、技術者としてのプライドを守ったのでセーフ(?)です。