knqyf263's blog

自分のためのメモとして残しておくためのブログ。

curlでKeyless Signingする (3) - Signed Certificate Timestamp編

はじめに

sigstoreのKeyless SigningをCosignコマンドを使わずに手動で頑張るシリーズの三本目です。誰にも望まれてないけどやりきります。

前回はFulcioで証明書の発行までを行いました。Fulcioから返された署名の中にSigned Certificate Timestamp (SCT) が含まれているので、今回はそれの検証をしてみます。シュッと終わらせるつもりがドチャクソハマったので1つの記事にしました。一つだけ言っておきたいのは手動でCLIで検証するとかわけのわからないことを言ってないでおとなしくプログラムを書いたほうが良いということです。

あとKeylessとか言ってますがもはや関係なくてただのSCT検証になっています。ただCosignのコードを読んで挙動を理解したものを書いているので、一般的なSCTとは少し異なる可能性があります。

Cosignのコードと言いましたが、Cosignは内部で以下のライブラリを利用しており大半の処理はこのライブラリから来ています。

github.com

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の仕組みは以下に書いてあります。前回の記事で説明したとおりです。

github.com

一応もう一度簡単に説明しておくと、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のハッシュを取るだけのようです。以下に詳しく書かれています。

zkat.hatenablog.com

落としてきた 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が証明書に埋め込まれていない場合のやり方は以下にあるので参考になりますが、今回は上記の通り埋め込まれているのでこのやり方ではうまく行かないです。

blog.pierky.com

証明書に埋め込まれている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でハッシュ計算しているのが分かります。

certificate-transparency-go/serialization.go at 100db00880e6af80af0132c01b09c9eb2c4ee2dc · google/certificate-transparency-go · GitHub

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を削除しています。

certificate-transparency-go/serialization.go at 100db00880e6af80af0132c01b09c9eb2c4ee2dc · google/certificate-transparency-go · GitHub

また、この 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に書いてありますが、以下の関数を見ると具体的に理解できると思います。

certificate-transparency-go/tls.go at d2e643e1a2f08235fcd8e1911a9d55c1b5b94cbc · google/certificate-transparency-go · GitHub

基本は上のフィールドから順番に固定長でビッグエンディアンで詰めていって、可変長なフィールドだけ最初に長さを入れるという簡単なエンコーディングです。

ということで手動で作ってみます。

# 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の検証をしました。これが出来たから何なんだという感じなんですが、すぐ出来るだろうと始めた手前、途中から意地になって絶対に成功させてやるみたいになってしまいました。正直時間を無駄にした感じが否めませんが、技術者としてのプライドを守ったのでセーフ(?)です。

そして今回APIを一度も叩いていないのでcurlをほぼ使っていないことにお気付きでしょうか。タイトルに偽りありです。