試運転ブログ

技術的なあれこれ

cosign実行時のやりとりを覗いてみた

cosign のコマンドを実行した時に、sigstore関連のサーバにどういったリクエストが飛ぶのかが気になり調べてみました。

$ cat test-result.json
{"passed": true}
$ COSIGN_EXPERIMENTAL=1 cosign attest  --type 'https://example.com/TestResult/v1' --predicate ./test-result.json otms61/test-custom-attest

cosignをある程度知っている前提で記事を書いているので、あまり馴染みがない場合には以下のブログなどを参考にしてみてください。

knqyf263.hatenablog.com

sil.hatenablog.com

blog.flatt.tech

扱うリクエス

  • oauth2
    • GET https://oauth2.sigstore.dev/auth/.well-known/openid-configuration
    • GET https://oauth2.sigstore.dev/auth/auth?access_type=online&client_id=sigstore&code_challenge=AVDpMwBa9ulXDqR3M45vmV9jOd1dzAjMYGviS0KJJII&code_challenge_method=S256&nonce=2Ev62T7F7w5FJ1oDf6fdpm13LoI&redirect_uri=http%3A%2F%2Flocalhost%3A51217%2Fauth%2Fcallback&response_type=code&scope=openid+email&state=2Ev62TAtN2xv5qOnmoU6jIvrV1r
      • redirect_uri: http://localhost:51217/auth/callback
      • scope: openid+email
    • GET http://localhost:51217/auth/callback?code=ek2umzn4x5ox4twmr4mu5x6y5&state=2Ev62TAtN2xv5qOnmoU6jIvrV1r
    • GET https://oauth2.sigstore.dev/auth/keys
  • fulcio
    • POST https://fulcio.sigstore.dev/api/v1/signingCert
  • rekor
    • POST https://rekor.sigstore.dev/api/v1/log/entries

docker hubとのやりとりは対象外としています。

リクエスト内に含まれている署名に関しては検証コードを書いてみたりしました。Goだとほぼコピペで終わりそうだったので、あえてpythonで書いています。

記事の中のTokenは一部変更していたり、期限も過ぎているので使用はできなくはなっているはずですが、何か問題ありそうなところがあったら教えていただけると助かります。

oauth2.sigstore.devとのやりとり

OpenIDプロバイダーの情報

まずはOpenIDプロバイダーの情報を取得します。

> GET /auth/.well-known/openid-configuration HTTP/1.1
> Host: oauth2.sigstore.dev


< HTTP/2.0 200 OK
< Content-Length: 1140
< Content-Type: application/json
< Date: Sun, 18 Sep 2022 01:11:43 GMT
< Strict-Transport-Security: max-age=15724800; includeSubDomains

{
    "issuer": "https://oauth2.sigstore.dev/auth",
    "authorization_endpoint": "https://oauth2.sigstore.dev/auth/auth",
    "token_endpoint": "https://oauth2.sigstore.dev/auth/token",
    "jwks_uri": "https://oauth2.sigstore.dev/auth/keys",
    "userinfo_endpoint": "https://oauth2.sigstore.dev/auth/userinfo",
    "device_authorization_endpoint": "https://oauth2.sigstore.dev/auth/device/code",
    "grant_types_supported": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"],
    "response_types_supported": ["code"],
    "subject_types_supported": ["public"],
    "id_token_signing_alg_values_supported": ["RS256"],
    "code_challenge_methods_supported": ["S256", "plain"],
    "scopes_supported": ["openid", "email", "groups", "profile", "offline_access"],
    "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
    "claims_supported": ["iss", "sub", "aud", "iat", "exp", "email", "email_verified", "locale", "name", "preferred_username", "at_hash"]
}

.well-known/openid-configuration ではOpenIDプロバイダーの情報を取得しています。ここで Authorization Endpointを取得しています。

.well-known/openid-configuration は、OpenID Connect Discovery 1.0 という仕様で定義されていて、以下のサイトの情報なども参考にしてください。 qiita.com

Authorization Endpointへの問い合わせ

ブラウザが開き以下のURLにアクセスします。

https://oauth2.sigstore.dev/auth/auth?access_type=online&client_id=sigstore&code_challenge=Sb0BAM0SKuusKmwGOUKD1jzyM4X5Q_Oba7j8V6bKvjI&code_challenge_method=S256&nonce=2EzYGdY34ebABazSvPKBSf9gKa3&redirect_uri=http://localhost:57543/auth/callback&response_type=code&scope=openid+email&state=2EzYGkmktp31kSIeTrRyPr3FFpp

このURLのパスは、.well-known/openid-configurationauthorization_endpoint です。

"authorization_endpoint": "https://oauth2.sigstore.dev/auth/auth",

パラメータから、code grant flowで、openid と emailのスコープを要求していることがわかります。

最終的にredirect_uriに指定されたアドレスに以下のようにcodeをつけてリダイレクトされます。

http://localhost:57543/auth/callback?code=jjbbyyoo5kff6ohrzlkciet6e&state=2EzYGkmktp31kSIeTrRyPr3FFpp

ブラウザで開かれてから、localhostにリダイレクトされるまで他にもいくつかリクエストが飛んでいます。

  • ブラウザで開かれるURL
    • https://oauth2.sigstore.dev/auth/auth?access_type=online&client_id=sigstore&code_challenge=Sb0BAM0SKuusKmwGOUKD1jzyM4X5Q_Oba7j8V6bKvjI&code_challenge_method=S256&nonce=2EzYGdY34ebABazSvPKBSf9gKa3&redirect_uri=http://localhost:57543/auth/callback&response_type=code&scope=openid+email&state=2EzYGkmktp31kSIeTrRyPr3FFpp
  • Googleを選択したときにリダイレクトされるURL
    • https://accounts.google.com/o/oauth2/v2/auth?client_id=237800849078-hri2ndt7gdafpf34kq8crd5sik9pe3so.apps.googleusercontent.com&redirect_uri=https://oauth2.sigstore.dev/auth/callback&response_type=code&scope=openid+email&state=aabb112233vxe2wr72yb2a22o
  • Googleから受け取ったcode( 4/00AAbbJrqZwRWjh0IBC19_8DIab-a2r6EHZXIYNpIcHWEGwpYCgly9NSbLcw1eK7XG0JFmg )を渡している
    • https://oauth2.sigstore.dev/auth/callback?state=aabb112233vxe2wr72yb2a22o&code=4/00AAbbJrqZwRWjh0IBC19_8DIab-a2r6EHZXIYNpIcHWEGwpYCgly9NSbLcw1eK7XG0JFmg&scope=email+https://www.googleapis.com/auth/userinfo.email+openid&authuser=0&prompt=none
  • 承認完了画面の表示
    • https://oauth2.sigstore.dev/auth/approval?req=aabb112233vxe2wr72yb2a22o&hmac=rZaSah1VGH47Dd8jZFdCUqCh-95J4jhk8jYTC72iVoM
  • localhostoauth2.sigstore.dev のcode( jjbbyyoo5kff6ohrzlkciet6e )を渡すリダイレクト
    • http://localhost:57543/auth/callback?code=jjbbyyoo5kff6ohrzlkciet6e&state=2EzYGkmktp31kSIeTrRyPr3FFpp

code が2度出てきて紛らわしいですが、最初の方がGoogleが発行し oauth2.sigstore.dev に渡すcodeで、oauth2.sigstore.dev のサーバ側でGoogleからトークンを取得するのに使っているものと思われます。最後の locahostへ伝えられるcodeは oauth2.sigstore.dev で発行されたcodeで、oauth2.sigstore.devtoken_endpointトークンを取得するのに使われます。

Id Tokenの取得

callbackで受け取ったcodeを oauth2.sigstore.dev に伝え、 oauth2.sigstore.dev のId Tokenを取得しています。

> POST /auth/token HTTP/1.1
> Host: oauth2.sigstore.dev
> Authorization: Basic c2lnc3RvcmU6
> Content-Length: 225
> Content-Type: application/x-www-form-urlencoded

code=jjbbyyoo5kff6ohrzlkciet6e&code_verifier=2Ev62YX6tFyMfI1sesgYp6ZRX4o2Ev62XJyVdyusyd32WyA2qbqGMF&grant_type=authorization_code&nonce=2EzYGdY34ebABazSvPKBSf9gKa3&redirect_uri=http%3A%2F%2Flocalhost%3A51217%2Fauth%2Fcallback

< HTTP/2.0 200 OK
< Cache-Control: no-store
< Content-Length: 2060
< Content-Type: application/json
< Date: Sun, 18 Sep 2022 01:11:49 GMT
< Pragma: no-cache
< Strict-Transport-Security: max-age=15724800; includeSubDomains

{
    "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYjMwMThlZThmZTM1NjdlY2JhODMwN2RlOWI0OWM5YWYzYTE5NmIifQ.eyJpc3MiOiJodHRwczovL29hdXRoMi5zaWdzdG9yZS5kZXYvYXV0aCIsInN1YiI6IkNoVXhNREU0TnprMk1qYzFORE13TnpVM056ZzRNVGdTSDJoMGRIQnpPaVV5UmlVeVJtRmpZMjkxYm5SekxtZHZiMmRzWlM1amIyMCIsImF1ZCI6InNpZ3N0b3JlIiwiZXhwIjoxNjYzNDYzNTY4LCJpYXQiOjE2NjM0NjM1MDgsIm5vbmNlIjoiMkV2NjJUN0Y3dzVGSjFvRGY2ZmRwbTEzTG9JIiwiYXRfaGFzaCI6IlVRajBIc3RrR2dhWkZRSWNVRGFYZVEiLCJlbWFpbCI6InNhc29ha2lyYTYxMTRAZ21haWwuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImZlZGVyYXRlZF9jbGFpbXMiOnsiY29ubmVjdG9yX2lkIjoiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tIiwidXNlcl9pZCI6IjEwMTg3OTYyNzU0MzA3NTc3ODgxOCJ9fQ.hlqM-fgzkaEjqN1EpOLS2ui5BcrD-f3ljm6R5Us_stwP1FoLERNoh38-oM79F_FKXJLbCeeBQV2vYrianeKo7ZeRFlkBj604nUyhWxrnTyaRyEVb3t09egEPe8wjrIR15y8to2I1emrn3GOZLTmLH71hrRN4H-2yse_1QQgtdoqwFJ6iANSyoq2XiCKsGFpEdgguQYUG_o2nE1Ur7WQ5Z6yzo_NFsrcfKqA19gOJl1nKOpeudxjGwVP0Q6rePMAnFgLN04cNFE2jeDD4_apcb2eLCO7FeQOeuF-85FoXsF9gNUIkUgyTEM0dAWQFqHH6vL4dtCVQ1jS0MveU4VTS00",
    "token_type": "bearer",
    "expires_in": 59,
    "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYjMwMThlZThmZTM1NjdlY2JhODMwN2RlOWI0OWM5YWYzYTE5NmIifQ.eyJpc3MiOiJodHRwczovL29hdXRoMi5zaWdzdG9yZS5kZXYvYXV0aCIsInN1YiI6IkNoVXhNREU0TnprMk1qYzFORE13TnpVM056ZzRNVGdTSDJoMGRIQnpPaVV5UmlVeVJtRmpZMjkxYm5SekxtZHZiMmRzWlM1amIyMCIsImF1ZCI6InNpZ3N0b3JlIiwiZXhwIjoxNjYzNDYzNTY4LCJpYXQiOjE2NjM0NjM1MDgsIm5vbmNlIjoiMkV2NjJUN0Y3dzVGSjFvRGY2ZmRwbTEzTG9JIiwiYXRfaGFzaCI6InIzWlVid3d5a2RHTVlwT0tUUEVjQ0EiLCJjX2hhc2giOiJlMVJLVXhHWlZPdnZacS1VbERQRFpBIiwiZW1haWwiOiJzYXNvYWtpcmE2MTE0QGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJmZWRlcmF0ZWRfY2xhaW1zIjp7ImNvbm5lY3Rvcl9pZCI6Imh0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbSIsInVzZXJfaWQiOiIxMDE4Nzk2Mjc1NDMwNzU3Nzg4MTgifX0.ir6acPSd58j9TSVEDTpc1zuIq9YSJvCb-S314uFFEtaJ01Iv8AdhRjRVjuO6EJPx75OwxqDvQOb6SgeCw9y5H0Emy75mjWPj4sCQXIuSRhLrJhJ13CaJ9g-Pdy1xAsk3OOd4U5VrSRqh6tvmRenrD6rMY5ee_m5NTFaFOpj9D133xND87o_HIxDI7EP-9rau9S_E6uv_zUAn4LEQRasXj-F9s1Yn33BuHkNlQS5Kln62qp9Rj8leAR6GW9xJNyMNN_VdQeIva7lHOCaK68d-GVPqKVjyzyL1PLJiNBIUKc4EsbNtI6phpAGj3OOGWn1UFN7MnDYLhESDrDntvoGF00"
}

このURLのパスは、.well-known/openid-configurationtoken_endpoint です。

"token_endpoint": "https://oauth2.sigstore.dev/auth/token",

Id Tokenの中身は、

Header:
{
    "alg": "RS256",
    "kid": "fab3018ee8fe3567ecba8307de9b49c9af3a196b"
}
Claims:
{
    "at_hash": "r3ZUbwwykdGMYpOKTPEcCA",
    "aud": "sigstore",
    "c_hash": "e1RKUxGZVOvvZq-UlDPDZA",
    "email": "sasoakira6114@gmail.com",
    "email_verified": true,
    "exp": 1663463568,
    "federated_claims": {
        "connector_id": "https://accounts.google.com",
        "user_id": "101879627543075778818"
    },
    "iat": 1663463508,
    "iss": "https://oauth2.sigstore.dev/auth",
    "nonce": "2Ev62T7F7w5FJ1oDf6fdpm13LoI",
    "sub": "ChUxMDE4Nzk2Mjc1NDMwNzU3Nzg4MTgSH2h0dHBzOiUyRiUyRmFjY291bnRzLmdvb2dsZS5jb20"
}

有効期限は59秒と短い値が採用されています。

"expires_in": 59

レスポンスヘッダーの時間より、Id Tokenが発行された時刻は 01:11:49

< Date: Sun, 18 Sep 2022 01:11:49 GMT

Id Token の clameの exp1663463568 なので、失効するのは 01:12:48 となっています。

> date -u -r "1663463568"
Sun Sep 18 01:12:48 UTC 2022

有効期限は59秒、発行時刻が 01:11:49で、IdTokenの失効時刻が 01:12:48 となっており、整合性が取れていることがわかります。

Id Token検証のための鍵の取得

Id Tokenを検証するために鍵を取得します。

> GET /auth/keys HTTP/1.1
> Host: oauth2.sigstore.dev

< HTTP/2.0 200 OK
< Cache-Control: max-age=8930, must-revalidate
< Content-Length: 1032
< Content-Type: application/json
< Date: Sun, 18 Sep 2022 01:11:49 GMT
< Strict-Transport-Security: max-age=15724800; includeSubDomains

{
    "keys": [
        {
            "use": "sig",
            "kty": "RSA",
            "kid": "fab3018ee8fe3567ecba8307de9b49c9af3a196b",
            "alg": "RS256",
            "n": "sgYrM7Fmkez4ruXBTmwNovlo0pDpCRmWuHvLBlStsXpZoiypNdUQ-9foCkA2JSDJ2ZH6YRuGZlQkHesLVVumOMITxz53-zmBuHdRtLBe4RG8hKDwEXTND0JS43-Ew9nzqpZy5xcZlXQGngGptDA_dCgNLRdiX6QTnLCyHQna4qUzum3CExyOCpyldIFBXL7XvNECWarrFAsLZzeWCRPpeMqd5jQ27DDEgzsQfd6Z3F-fyDcp64KYNGIcj64wYnKErM2p7IsfpjqURD2hhEtXcxgd7_b31GUbDP1r24XXsAgSeGNRSw5XNhbzVn80x_EyY7VHAGcFWsnYjFfLJ4gaJQ",
            "e": "AQAB"
        },
        {
            "use": "sig",
            "kty": "RSA",
            "kid": "7f5cc041e09c5ca3df4f88eff9f75149f973250a",
            "alg": "RS256",
            "n": "li7QSTuaFhmtFU2Td12ju7dD9Q-_igOhSd5yXQxHXLpZInbcyL8R7MdiYIHOG6t2mLpD8QKwEL5qwnEjCGqx-REUARyRT6pWqHDtlGBvekabYNZIc4bCWMxkpcvDNpLvIhr-6C8LC7ocpZiZtcKuAtYWW62lzEtAhQEC2WdFeBONBwHthh-M0edRfMlxciKEH89q75AGG-4JeSWPUW6eYEoOw4NxupKGEBu4C7rSBJb1jJxDZq2vfMBHyCmlADYGaw_hbD-HN6Yny2-CXrJpv1FkKI4bea_NnWG85SEE9COQzhsiKvFJDWW2ZPoxlVif4OcVBz9K74IrKxXtfBxVuQ",
            "e": "AQAB"
        }
    ]
}

このURLのパスは、.well-known/openid-configurationjwks_uri です。

"jwks_uri": "https://oauth2.sigstore.dev/auth/keys",

Id Tokenの検証については以下のブログが参考になります。

www.sambaiz.net

fulcioとのやりとり

oauth2.sigstore.dev で発行したIdTokenをもとに証明書を発行します。

> POST /api/v1/signingCert HTTP/1.1
> Host: fulcio.sigstore.dev
> Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImZhYjMwMThlZThmZTM1NjdlY2JhODMwN2RlOWI0OWM5YWYzYTE5NmIifQ.eyJpc3MiOiJodHRwczovL29hdXRoMi5zaWdzdG9yZS5kZXYvYXV0aCIsInN1YiI6IkNoVXhNREU0TnprMk1qYzFORE13TnpVM056ZzRNVGdTSDJoMGRIQnpPaVV5UmlVeVJtRmpZMjkxYm5SekxtZHZiMmRzWlM1amIyMCIsImF1ZCI6InNpZ3N0b3JlIiwiZXhwIjoxNjYzNDYzNTY4LCJpYXQiOjE2NjM0NjM1MDgsIm5vbmNlIjoiMkV2NjJUN0Y3dzVGSjFvRGY2ZmRwbTEzTG9JIiwiYXRfaGFzaCI6InIzWlVid3d5a2RHTVlwT0tUUEVjQ0EiLCJjX2hhc2giOiJlMVJLVXhHWlZPdnZacS1VbERQRFpBIiwiZW1haWwiOiJzYXNvYWtpcmE2MTE0QGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJmZWRlcmF0ZWRfY2xhaW1zIjp7ImNvbm5lY3Rvcl9pZCI6Imh0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbSIsInVzZXJfaWQiOiIxMDE4Nzk2Mjc1NDMwNzU3Nzg4MTgifX0.ir6acPSd58j9TSVEDTpc1zuIq9YSJvCb-S314uFFEtaJ01Iv8AdhRjRVjuO6EJPx75OwxqDvQOb6SgeCw9y5H0Emy75mjWPj4sCQXIuSRhLrJhJ13CaJ9g-Pdy1xAsk3OOd4U5VrSRqh6tvmRenrD6rMY5ee_m5NTFaFOpj9D133xND87o_HIxDI7EP-9rau9S_E6uv_zUAn4LEQRasXj-F9s1Yn33BuHkNlQS5Kln62qp9Rj8leAR6GW9xJNyMNN_VdQeIva7lHOCaK68d-GVPqKVjyzyL1PLJiNBIUKc4EsbNtI6phpAGj3OOGWn1UFN7MnDYLhESDrDntvoGF00
> Content-Length: 325
> Content-Type: application/json

{
    "publicKey": {
        "content": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEi2pKkJdJxrDZrCuDT+JDN/63RS9Hcz1yr4YBYVYLnI4hnFqnAMvx68xbjWXW3O8jEWIsgkMGcGa+giYIDV0bFQ==",
        "algorithm": "ecdsa"
    },
    "signedEmailAddress": "MEQCIEme0ouHErG7JnhFx94DtQuUGOJ38N+Uj1cVp8P/fM2MAiBv95ZfvLi1U9taWbA1p/T9FTrKZFmkqQF7XhZq3h+e2g==",
    "certificateSigningRequest": null
}



< HTTP/2.0 201 Created
< Content-Type: application/pem-certificate-chain
< Date: Sun, 18 Sep 2022 01:11:49 GMT
< Strict-Transport-Security: max-age=15724800; includeSubDomains
< Vary: Origin

-----BEGIN CERTIFICATE-----
MIICpDCCAimgAwIBAgIUOrq8rexC31ayBokWjaOX28PUdEYwCgYIKoZIzj0EAwMw
NzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRl
cm1lZGlhdGUwHhcNMjIwOTE4MDExMTQ5WhcNMjIwOTE4MDEyMTQ5WjAAMFkwEwYH
KoZIzj0CAQYIKoZIzj0DAQcDQgAEi2pKkJdJxrDZrCuDT+JDN/63RS9Hcz1yr4YB
YVYLnI4hnFqnAMvx68xbjWXW3O8jEWIsgkMGcGa+giYIDV0bFaOCAUgwggFEMA4G
A1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUOIuA
oH2TmkY/ULd1symjM3KMmVswHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4Y
ZD8wJQYDVR0RAQH/BBswGYEXc2Fzb2FraXJhNjExNEBnbWFpbC5jb20wKQYKKwYB
BAGDvzABAQQbaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tMIGKBgorBgEEAdZ5
AgQCBHwEegB4AHYACGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3IAAAGD
TibPoAAABAMARzBFAiBBYUxFpEdyvAakCjn+oeSVjXHACO5cy3wbdnG1yla+8wIh
AP/M0EOBYQeM0UC/PBbgkhH2qY3VZbRPoV/ZSLDre7uVMAoGCCqGSM49BAMDA2kA
MGYCMQDpGJ4wxnLlNKT1lzpbwCdUAmcftAoZohq62z7NkmTgXJqET8VdiMCTagO4
4Jkb4GICMQDz0lHSw5SYgc1JF2RP0yScTIlkQ2Io4SG8efZwtJw77vlNhAYxBMx5
RX2TKy4AeO0=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMw
KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y
MjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3Jl
LmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0C
AQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV7
7LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS
0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYB
BQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjp
KFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZI
zj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJR
nZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsP
mygUY7Ii2zbdCdliiow=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMw
KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y
MTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3Jl
LmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7
XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxex
X69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92j
YzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRY
wB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQ
KsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCM
WP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9
TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ
-----END CERTIFICATE-----

リクエス

keyless sigingの時のcosign attest コマンド内では、コマンド実行中に秘密鍵を生成します。この秘密鍵に対応する公開鍵と、この秘密鍵で署名したメールアドレスがリクエストには含まれています。

cosign/fulcio.go at 7ba521444f9fcfdf2e1e5936c05834597674e6c9 · sigstore/cosign · GitHub

リクエストに含まれるpublicKeyは、ASN.1 DER 形式にエンコードされています。

cosign/fulcio.go at 7ba521444f9fcfdf2e1e5936c05834597674e6c9 · sigstore/cosign · GitHub

signedEmailAddress は、Id Tokenに含まれているメールアドレス( sasoakira6114@gmail.com )を秘密鍵で署名したシグネチャをDER形式にしたものです。

import base64
import hashlib

import ecdsa
from ecdsa.util import sigdecode_der

c = {
    "publicKey": {
        "content": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEi2pKkJdJxrDZrCuDT+JDN/63RS9Hcz1yr4YBYVYLnI4hnFqnAMvx68xbjWXW3O8jEWIsgkMGcGa+giYIDV0bFQ==",
        "algorithm": "ecdsa",
    },
    "signedEmailAddress": "MEQCIEme0ouHErG7JnhFx94DtQuUGOJ38N+Uj1cVp8P/fM2MAiBv95ZfvLi1U9taWbA1p/T9FTrKZFmkqQF7XhZq3h+e2g==",
    "certificateSigningRequest": None,
}

v = ecdsa.VerifyingKey.from_der(base64.b64decode(c["publicKey"]["content"]))
# verifyに失敗するとFalseがかえる
print(
    v.verify(
        signature=base64.b64decode(c["signedEmailAddress"]),
        data=b"sasoakira6114@gmail.com",
        hashfunc=hashlib.sha256,
        sigdecode=sigdecode_der,
    )
)
"""
$ python x.py
True
"""

元のメールアドレスは、リクエストボディには含まず、AuthorizationヘッダーにId Tokenが含まれているためサーバサイドで検証することが可能です。

レスポンス

レスポンスの証明書に含まれる情報を見ていきます。 証明書の仕様 や、fulcioが証明書発行のリクエストを受けた時の挙動を説明したhow-certificate-issuing-works.md を知ると分かりやすいです。

how-certificate-issuing-works.md2 | Authentication は、Id Token検証のための鍵の取得 でクライアント側でもId Tokenの検証をしたのと同様のことをfulcioでも行います。 3 | Verifying the challengefulcioとのやりとりリクエスト の検証で確かめたことと同様のことをしています。challengeと呼んでいるものは、signedEmailAddressのことです。

以降は発行された証明書を見ながら確認していきます。

レスポンスには3つの証明書チェインで返ってきますが、3番目がfulcioのルート証明書で、2番目がfulcioの中間証明書で、利用者のリクエストをもとに発行されたのは1番目の証明書です。

fulcioの証明書チェーン

まずは、中間証明書とfulcioのルート証明書をみていきます。

中間証明書の中身

$ openssl x509 -text -in intermediate.crt
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:
                keyid: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
         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

ルート証明書の中身

openssl x509 -text -in root.crt
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            b6:4d:00:f1:5d:c4:73:f0:8d:e0:e5:a0:3c:32:60:28:40:3b:fe
    Signature Algorithm: ecdsa-with-SHA384
        Issuer: O=sigstore.dev, CN=sigstore
        Validity
            Not Before: Oct  7 13:56:59 2021 GMT
            Not After : Oct  5 13:56:58 2031 GMT
        Subject: O=sigstore.dev, CN=sigstore
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (384 bit)
                pub:
                    04:fb:5d:e1:53:e2:b6:f7:3d:01:b0:4b:82:1a:8e:
                    d2:e4:df:f3:a5:9e:98:1a:9e:06:81:72:56:29:b1:
                    80:6b:e6:2f:b8:ca:70:74:ed:c7:9b:dc:b3:f4:38:
                    83:99:77:17:b1:5f:af:5c:e6:25:6e:c8:94:50:f8:
                    7c:f4:e7:28:be:50:5d:ee:05:60:25:1e:98:92:e6:
                    c8:74:f8:7d:86:1c:4e:d2:5e:b9:35:10:2e:66:d5:
                    3a:f5:f4:bf:60:83:dd
                ASN1 OID: secp384r1
                NIST CURVE: P-384
        X509v3 extensions:
            X509v3 Key Usage: critical
                Certificate Sign, CRL Sign
            X509v3 Basic Constraints: critical
                CA:TRUE
            X509v3 Subject Key Identifier:
                58:C0:1E:5F:91:45:A5:66:A9:7A:CC:90:A1:93:22:D0:2A:C5:C5:FA
            X509v3 Authority Key Identifier:
                keyid: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
         30:66:02:31:00:8f:59:c7:79:76:69:fb:5d:cd:58:13:5a:f8:
         40:ec:0c:ff:06:d5:65:a0:d6:d0:8c:58:ff:d6:1c:fa:a9:69:
         5a:34:8e:1b:30:78:d1:59:81:2b:34:78:4e:f0:60:8e:2a:02:
         31:00:d9:60:7d:a2:df:7c:b0:89:28:17:7b:d9:61:d7:77:fd:
         5b:56:07:96:fd:4c:d3:1e:6b:b2:31:fe:cb:49:e5:37:dc:2c:
         b7:80:04:b1:38:04:d2:4e:b1:0e:2f:9c:11:c9

証明書チェーンになっていることも確認しておきます。

$ openssl verify -verbose -CAfile root.crt.pem intermediate.crt.pem
intermediate.crt: OK

$ cat intermediate.crt.pem root.crt.pem > p.crt.pem
$ openssl verify -verbose -CAfile  p.crt.pem user.crt.pem
user.crt:
error 10 at 0 depth lookup:certificate has expired
OK

証明書の有効期限が10分なので、検証した時には期限切れになってしまっていますが、証明書のチェーンはできていることがわかります。

TUFによる証明書の管理

このfulcioのルート証明書sigstore/sigstore という、sigstoreのライブラリがまとまっているレポジトリ内にも置かれています。一方で、中間証明書はこのレポジトリには置かれていないようでした。

github.com

コードを見るとTUF(The Update Framework) という仕組みを使って証明書を管理し、アップデートの対応をおこなっているようです。

github.com

DefaultRemoteRootにアクセスするとファイルのリストを閲覧可能です。

const (
    // DefaultRemoteRoot is the default remote TUF root location.
    DefaultRemoteRoot = "https://sigstore-tuf-root.storage.googleapis.com"

)

sigstore/client.go at 181eb26ad2d04c11a387c35efdecef307c941541 · sigstore/sigstore · GitHub

このファイルのリスト内に、先ほど確認した証明書と同様のルート証明書targets/fulcio_v1.crt.pem )と週刊証明書( targets/fulcio_intermediate_v1.crt.pem )というファイルが存在します。また、後ほど出てくるSCTの署名検証に使う公開鍵( targets/ctfe.pub )も含まれています。

TUFのrootパスにアクセス時

それぞれ、RootのURLにパスを追加するとファイルをダウロードすることが可能です。中身も先ほどと同様であることがわかります。

> curl https://sigstore-tuf-root.storage.googleapis.com/targets/fulcio_v1.crt.pem
-----BEGIN CERTIFICATE-----
MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMw
KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y
MTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3Jl
LmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7
XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxex
X69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92j
YzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRY
wB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQ
KsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCM
WP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9
TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ
-----END CERTIFICATE-----

> curl https://sigstore-tuf-root.storage.googleapis.com/targets/fulcio_intermediate_v1.crt.pem
-----BEGIN CERTIFICATE-----
MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMw
KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y
MjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3Jl
LmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0C
AQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV7
7LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS
0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYB
BQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjp
KFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZI
zj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJR
nZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsP
mygUY7Ii2zbdCdliiow=
-----END CERTIFICATE-----

TUFについて詳しいことは分かっていませんが、コード実行時にremoteのファイルリストを確認し、証明書を取得していると思われます。

ユーザの証明書

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            3a:ba:bc:ad:ec:42:df:56:b2:06:89:16:8d:a3:97:db:c3:d4:74:46
        Signature Algorithm: ecdsa-with-SHA384
        Issuer: O = sigstore.dev, CN = sigstore-intermediate
        Validity
            Not Before: Sep 18 01:11:49 2022 GMT
            Not After : Sep 18 01:21:49 2022 GMT
        Subject:
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
                pub:
                    04:8b:6a:4a:90:97:49:c6:b0:d9:ac:2b:83:4f:e2:
                    43:37:fe:b7:45:2f:47:73:3d:72:af:86:01:61:56:
                    0b:9c:8e:21:9c:5a:a7:00:cb:f1:eb:cc:5b:8d:65:
                    d6:dc:ef:23:11:62:2c:82:43:06:70:66:be:82:26:
                    08:0d:5d:1b:15
                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:
                38:8B:80:A0:7D:93:9A:46:3F:50:B7:75:B3:29:A3:33:72:8C:99:5B
            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:sasoakira6114@gmail.com
            1.3.6.1.4.1.57264.1.1:
                https://accounts.google.com
            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 18 01:11:49.920 2022 GMT
                    Extensions: none
                    Signature : ecdsa-with-SHA256
                                30:45:02:20:41:61:4C:45:A4:47:72:BC:06:A4:0A:39:
                                FE:A1:E4:95:8D:71:C0:08:EE:5C:CB:7C:1B:76:71:B5:
                                CA:56:BE:F3:02:21:00:FF:CC:D0:43:81:61:07:8C:D1:
                                40:BF:3C:16:E0:92:11:F6:A9:8D:D5:65:B4:4F:A1:5F:
                                D9:48:B0:EB:7B:BB:95
    Signature Algorithm: ecdsa-with-SHA384
    Signature Value:
        30:66:02:31:00:e9:18:9e:30:c6:72:e5:34:a4:f5:97:3a:5b:
        c0:27:54:02:67:1f:b4:0a:19:a2:1a:ba:db:3e:cd:92:64:e0:
        5c:9a:84:4f:c5:5d:88:c0:93:6a:03:b8:e0:99:1b:e0:62:02:
        31:00:f3:d2:51:d2:c3:94:98:81:cd:49:17:64:4f:d3:24:9c:
        4c:89:64:43:62:28:e1:21:bc:79:f6:70:b4:9c:3b:ee:f9:4d:
        84:06:31:04:cc:79:45:7d:93:2b:2e:00:78:ed

この証明書はリクエストで送った公開鍵に対する証明書といくつかの拡張フィールドに値が含まれています。 how-certificate-issuing-works.md4 | Constructing a certificate の画像からもわかるかと思います。

https://raw.githubusercontent.com/sigstore/fulcio/main/docs/img/create-certificate.png

https://raw.githubusercontent.com/sigstore/fulcio/main/docs/img/create-certificate.png:image=https://raw.githubusercontent.com/sigstore/fulcio/main/docs/img/create-certificate.png

        Validity
            Not Before: Sep 18 01:11:49 2022 GMT
            Not After : Sep 18 01:21:49 2022 GMT

この証明書の有効期限は10分間となっています。

この証明書に含まれている公開鍵を使って、先ほどのsignedEmailAddressできることを以下のようなコードで検証することができます。

import base64
from cryptography.x509 import load_pem_x509_certificate

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes


cert = """-----BEGIN CERTIFICATE-----
MIICpDCCAimgAwIBAgIUOrq8rexC31ayBokWjaOX28PUdEYwCgYIKoZIzj0EAwMw
NzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRl
cm1lZGlhdGUwHhcNMjIwOTE4MDExMTQ5WhcNMjIwOTE4MDEyMTQ5WjAAMFkwEwYH
KoZIzj0CAQYIKoZIzj0DAQcDQgAEi2pKkJdJxrDZrCuDT+JDN/63RS9Hcz1yr4YB
YVYLnI4hnFqnAMvx68xbjWXW3O8jEWIsgkMGcGa+giYIDV0bFaOCAUgwggFEMA4G
A1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUOIuA
oH2TmkY/ULd1symjM3KMmVswHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4Y
ZD8wJQYDVR0RAQH/BBswGYEXc2Fzb2FraXJhNjExNEBnbWFpbC5jb20wKQYKKwYB
BAGDvzABAQQbaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tMIGKBgorBgEEAdZ5
AgQCBHwEegB4AHYACGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3IAAAGD
TibPoAAABAMARzBFAiBBYUxFpEdyvAakCjn+oeSVjXHACO5cy3wbdnG1yla+8wIh
AP/M0EOBYQeM0UC/PBbgkhH2qY3VZbRPoV/ZSLDre7uVMAoGCCqGSM49BAMDA2kA
MGYCMQDpGJ4wxnLlNKT1lzpbwCdUAmcftAoZohq62z7NkmTgXJqET8VdiMCTagO4
4Jkb4GICMQDz0lHSw5SYgc1JF2RP0yScTIlkQ2Io4SG8efZwtJw77vlNhAYxBMx5
RX2TKy4AeO0=
-----END CERTIFICATE-----"""


c = {
    "signedEmailAddress": "MEQCIEme0ouHErG7JnhFx94DtQuUGOJ38N+Uj1cVp8P/fM2MAiBv95ZfvLi1U9taWbA1p/T9FTrKZFmkqQF7XhZq3h+e2g==",
    "certificateSigningRequest": None,
}


z = load_pem_x509_certificate(cert.encode("utf-8"))

# verifyに失敗すると例外があがる
print(
    z.public_key().verify(
        signature=base64.b64decode(c["signedEmailAddress"]),
        data=b"sasoakira6114@gmail.com",
        signature_algorithm=ec.ECDSA(hashes.SHA256()),
    )
)

"""
$ python x.py
None
"""

X509v3 extensionsについて

X509v3 Subject Alternative Name: critical
    email:sasoakira6114@gmail.com

Subject Alternative Name にはメールアドレスが含まれています。

/docs/man1.0.2/man5/x509v3_config.html

1.3.6.1.4.1.57264.1.1:
    https://accounts.google.com

Object Identifier(OID)が 1.3.6.1.4.1.57264 は、sigstoreによって定義されているフィールドのようです。 fulcio/oid-info.md at main · sigstore/fulcio · GitHub

1.3.6.1.4.1.57264.1.1 は、 Issuerの情報らしく、Googleアカウントと紐づけた場合には https://accounts.google.com になりました。 また、Github アカウントと紐づけた場合には、 https://github.com/login/oauth となりました。

1.3.6.1.4.1.57264 には、他にもGithub ActionのWorkflowで作られた場合にはworkflowの情報が含まれるようです。

X509v3 extensionsの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 18 01:11:49.920 2022 GMT
        Extensions: none
        Signature : ecdsa-with-SHA256
                    30:45:02:20:41:61:4C:45:A4:47:72:BC:06:A4:0A:39:
                    FE:A1:E4:95:8D:71:C0:08:EE:5C:CB:7C:1B:76:71:B5:
                    CA:56:BE:F3:02:21:00:FF:CC:D0:43:81:61:07:8C:D1:
                    40:BF:3C:16:E0:92:11:F6:A9:8D:D5:65:B4:4F:A1:5F:
                    D9:48:B0:EB:7B:BB:95

CT Precertificate SCTsのOIDは 1.3.6.1.4.1.11129.2.4.2 で、説明は以下となっています。

One or more RFC 6962 Signed Certificate Timestamps

RFC 6962 とはCertificate Transparencyに関するものです。 6 | Certificate Transparency log inclusion にも Certificate Transparency に証明書発行のログをアップロードして、その情報を埋め込んだ証明書を作っていることが分かります。

certificate.transparency.dev

SCTの仕組みについては、Googleのサイト や、以下のような資料が詳しいです。 https://www.jnsa.org/seminar/pki-day/2016/data/1-2_oosumi.pdf

Log IDは、署名に使われた公開鍵のDER形式のsha256の値です。公開鍵はTUFのファイルリストから取得可能です。 計算方法は以下のブログが参考になります。

zkat.hatenablog.com

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

LogIDを公開鍵から求めてみます。

$ curl -sO https://sigstore-tuf-root.storage.googleapis.com/targets/ctfe.pub

$ cat ctfe.pub
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3Pyu
dDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==
-----END PUBLIC KEY-----⏎

$ openssl ec -outform der -pubin -in ctfe.pub | shasum -a 256
read EC key
writing EC key
086092f02852ff6845d1d16b27849c456718ac163dc338d26de6bc2206366f72  -

フォーマットの違いはありますが、LogIDが一致することが確認できました。

SCTを検証しているコードはこの関数。

cosign/verify.go at 31e665415f2da47356e4657e751443fcf5f394ed · sigstore/cosign · GitHub

rekorとのやりとり

rekorに、attestationと証明書を登録します。

> POST /api/v1/log/entries HTTP/1.1
> Host: rekor.sigstore.dev
> Accept: application/json
> Content-Length: 2080
> Content-Type: application/json

{
    "apiVersion": "0.0.1",
    "spec": {
        "content": {
            "envelope": "{\"payloadType\":\"application/vnd.in-toto+json\",\"payload\":\"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2V4YW1wbGUuY29tL1Rlc3RSZXN1bHQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiaW5kZXguZG9ja2VyLmlvL290bXM2MS90ZXN0LWN1c3RvbS1hdHRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiOTIyNTE0NTgwODhjNjM4MDYxY2RhOGZkOGI0MDNiNzZkNjYxYTRkYzZiN2VlNzFiNmFmZmNmMTg3MjU1N2IyYiJ9fV0sInByZWRpY2F0ZSI6eyJwYXNzZWQiOnRydWV9fQ==\",\"signatures\":[{\"keyid\":\"\",\"sig\":\"MEUCIAs21tx59kbSkqA9kRE/b3De51SN62cbJSCWG6x9PDO7AiEAquGbnjchwI4D0P4m4njMnH2AsnR/zpzosRogQugDkJ8=\"}]}",
            "hash": {
                "algorithm": "sha256",
                "value": "505eaf34d3fd4b40b3d60734b1f14d1792d4dc91b0b2677993fd37b8d493a466"
            }
        },
        "publicKey": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNwRENDQWltZ0F3SUJBZ0lVT3JxOHJleEMzMWF5Qm9rV2phT1gyOFBVZEVZd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpJd09URTRNREV4TVRRNVdoY05Nakl3T1RFNE1ERXlNVFE1V2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVpMnBLa0pkSnhyRFpyQ3VEVCtKRE4vNjNSUzlIY3oxeXI0WUIKWVZZTG5JNGhuRnFuQU12eDY4eGJqV1hXM084akVXSXNna01HY0dhK2dpWUlEVjBiRmFPQ0FVZ3dnZ0ZFTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVPSXVBCm9IMlRta1kvVUxkMXN5bWpNM0tNbVZzd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d0pRWURWUjBSQVFIL0JCc3dHWUVYYzJGemIyRnJhWEpoTmpFeE5FQm5iV0ZwYkM1amIyMHdLUVlLS3dZQgpCQUdEdnpBQkFRUWJhSFIwY0hNNkx5OWhZMk52ZFc1MGN5NW5iMjluYkdVdVkyOXRNSUdLQmdvckJnRUVBZFo1CkFnUUNCSHdFZWdCNEFIWUFDR0NTOENoUy8yaEYwZEZySjRTY1JXY1lyQlk5d3pqU2JlYThJZ1kyYjNJQUFBR0QKVGliUG9BQUFCQU1BUnpCRkFpQkJZVXhGcEVkeXZBYWtDam4rb2VTVmpYSEFDTzVjeTN3YmRuRzF5bGErOHdJaApBUC9NMEVPQllRZU0wVUMvUEJiZ2toSDJxWTNWWmJSUG9WL1pTTERyZTd1Vk1Bb0dDQ3FHU000OUJBTURBMmtBCk1HWUNNUURwR0o0d3huTGxOS1QxbHpwYndDZFVBbWNmdEFvWm9ocTYyejdOa21UZ1hKcUVUOFZkaU1DVGFnTzQKNEprYjRHSUNNUUR6MGxIU3c1U1lnYzFKRjJSUDB5U2NUSWxrUTJJbzRTRzhlZlp3dEp3Nzd2bE5oQVl4Qk14NQpSWDJUS3k0QWVPMD0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo="
    },
    "kind": "intoto"
}

< HTTP/2.0 201 Created
< Content-Type: application/json
< Date: Sun, 18 Sep 2022 01:11:53 GMT
< Location: /api/v1/log/entries/4c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1
< Strict-Transport-Security: max-age=15724800; includeSubDomains
< Vary: Origin

{
    "4c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1": {
        "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaW50b3RvIiwic3BlYyI6eyJjb250ZW50Ijp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI1MDVlYWYzNGQzZmQ0YjQwYjNkNjA3MzRiMWYxNGQxNzkyZDRkYzkxYjBiMjY3Nzk5M2ZkMzdiOGQ0OTNhNDY2In0sInBheWxvYWRIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZDRjZjI2YWUwZmQ3OWRhMTU2ZjAzYzgwNjY2ZTFjZWFmZTljNjQ4ZWU2MGQwYjM2ODBlNGU4ZTQzZDNiZGNiMCJ9fSwicHVibGljS2V5IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTndSRU5EUVdsdFowRjNTVUpCWjBsVlQzSnhPSEpsZUVNek1XRjVRbTlyVjJwaFQxZ3lPRkJWWkVWWmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEpkMDlVUlRSTlJFVjRUVlJSTlZkb1kwNU5ha2wzVDFSRk5FMUVSWGxOVkZFMVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZwTW5CTGEwcGtTbmh5UkZweVEzVkVWQ3RLUkU0dk5qTlNVemxJWTNveGVYSTBXVUlLV1ZaWlRHNUpOR2h1Um5GdVFVMTJlRFk0ZUdKcVYxaFhNMDg0YWtWWFNYTm5hMDFIWTBkaEsyZHBXVWxFVmpCaVJtRlBRMEZWWjNkblowWkZUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZQU1hWQkNtOUlNbFJ0YTFrdlZVeGtNWE41YldwTk0wdE5iVlp6ZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBwUldVUldVakJTUVZGSUwwSkNjM2RIV1VWWVl6SkdlbUl5Um5KaFdFcG9UbXBGZUU1RlFtNWlWMFp3WWtNMWFtSXlNSGRMVVZsTFMzZFpRZ3BDUVVkRWRucEJRa0ZSVVdKaFNGSXdZMGhOTmt4NU9XaFpNazUyWkZjMU1HTjVOVzVpTWpsdVlrZFZkVmt5T1hSTlNVZExRbWR2Y2tKblJVVkJaRm8xQ2tGblVVTkNTSGRGWldkQ05FRklXVUZEUjBOVE9FTm9VeTh5YUVZd1pFWnlTalJUWTFKWFkxbHlRbGs1ZDNwcVUySmxZVGhKWjFreVlqTkpRVUZCUjBRS1ZHbGlVRzlCUVVGQ1FVMUJVbnBDUmtGcFFrSlpWWGhHY0VWa2VYWkJZV3REYW00cmIyVlRWbXBZU0VGRFR6VmplVE4zWW1SdVJ6RjViR0VyT0hkSmFBcEJVQzlOTUVWUFFsbFJaVTB3VlVNdlVFSmlaMnRvU0RKeFdUTldXbUpTVUc5V0wxcFRURVJ5WlRkMVZrMUJiMGREUTNGSFUwMDBPVUpCVFVSQk1tdEJDazFIV1VOTlVVUndSMG8wZDNodVRHeE9TMVF4Ykhwd1luZERaRlZCYldObWRFRnZXbTlvY1RZeWVqZE9hMjFVWjFoS2NVVlVPRlprYVUxRFZHRm5UelFLTkVwcllqUkhTVU5OVVVSNk1HeElVM2MxVTFsbll6RktSakpTVURCNVUyTlVTV3hyVVRKSmJ6UlRSemhsWmxwM2RFcDNOemQyYkU1b1FWbDRRazE0TlFwU1dESlVTM2swUVdWUE1EMEtMUzB0TFMxRlRrUWdRMFZTVkVsR1NVTkJWRVV0TFMwdExRbz0ifX0=",
        "integratedTime": 1663463513,
        "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
        "logIndex": 3527741,
        "verification": {
            "signedEntryTimestamp": "MEUCIQDDPOjpA4pKVf0go1NclpptEBALyqqmtJlhKi13w5CyxgIgB4lxM6RktSfMtoaeO8DffoN51ymoAZWAmJjvL6UrNz0="
        }
    }
}

リクエス

まず、spec.publicKey を見ていきます。ここには、fulcioから取得したユーザの証明書が入っています。

import base64

r = {
    "apiVersion": "0.0.1",
    "spec": {
        "content": {
            "envelope": '{"payloadType":"application/vnd.in-toto+json","payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2V4YW1wbGUuY29tL1Rlc3RSZXN1bHQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiaW5kZXguZG9ja2VyLmlvL290bXM2MS90ZXN0LWN1c3RvbS1hdHRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiOTIyNTE0NTgwODhjNjM4MDYxY2RhOGZkOGI0MDNiNzZkNjYxYTRkYzZiN2VlNzFiNmFmZmNmMTg3MjU1N2IyYiJ9fV0sInByZWRpY2F0ZSI6eyJwYXNzZWQiOnRydWV9fQ==","signatures":[{"keyid":"","sig":"MEUCIAs21tx59kbSkqA9kRE/b3De51SN62cbJSCWG6x9PDO7AiEAquGbnjchwI4D0P4m4njMnH2AsnR/zpzosRogQugDkJ8="}]}',
            "hash": {
                "algorithm": "sha256",
                "value": "505eaf34d3fd4b40b3d60734b1f14d1792d4dc91b0b2677993fd37b8d493a466",
            },
        },
        "publicKey": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNwRENDQWltZ0F3SUJBZ0lVT3JxOHJleEMzMWF5Qm9rV2phT1gyOFBVZEVZd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpJd09URTRNREV4TVRRNVdoY05Nakl3T1RFNE1ERXlNVFE1V2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVpMnBLa0pkSnhyRFpyQ3VEVCtKRE4vNjNSUzlIY3oxeXI0WUIKWVZZTG5JNGhuRnFuQU12eDY4eGJqV1hXM084akVXSXNna01HY0dhK2dpWUlEVjBiRmFPQ0FVZ3dnZ0ZFTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVPSXVBCm9IMlRta1kvVUxkMXN5bWpNM0tNbVZzd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d0pRWURWUjBSQVFIL0JCc3dHWUVYYzJGemIyRnJhWEpoTmpFeE5FQm5iV0ZwYkM1amIyMHdLUVlLS3dZQgpCQUdEdnpBQkFRUWJhSFIwY0hNNkx5OWhZMk52ZFc1MGN5NW5iMjluYkdVdVkyOXRNSUdLQmdvckJnRUVBZFo1CkFnUUNCSHdFZWdCNEFIWUFDR0NTOENoUy8yaEYwZEZySjRTY1JXY1lyQlk5d3pqU2JlYThJZ1kyYjNJQUFBR0QKVGliUG9BQUFCQU1BUnpCRkFpQkJZVXhGcEVkeXZBYWtDam4rb2VTVmpYSEFDTzVjeTN3YmRuRzF5bGErOHdJaApBUC9NMEVPQllRZU0wVUMvUEJiZ2toSDJxWTNWWmJSUG9WL1pTTERyZTd1Vk1Bb0dDQ3FHU000OUJBTURBMmtBCk1HWUNNUURwR0o0d3huTGxOS1QxbHpwYndDZFVBbWNmdEFvWm9ocTYyejdOa21UZ1hKcUVUOFZkaU1DVGFnTzQKNEprYjRHSUNNUUR6MGxIU3c1U1lnYzFKRjJSUDB5U2NUSWxrUTJJbzRTRzhlZlp3dEp3Nzd2bE5oQVl4Qk14NQpSWDJUS3k0QWVPMD0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=",
    },
    "kind": "intoto",
}

print(base64.b64decode(r["spec"]["publicKey"]).decode("utf-8"))

"""
> python b.py
-----BEGIN CERTIFICATE-----
MIICpDCCAimgAwIBAgIUOrq8rexC31ayBokWjaOX28PUdEYwCgYIKoZIzj0EAwMw
NzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRl
cm1lZGlhdGUwHhcNMjIwOTE4MDExMTQ5WhcNMjIwOTE4MDEyMTQ5WjAAMFkwEwYH
KoZIzj0CAQYIKoZIzj0DAQcDQgAEi2pKkJdJxrDZrCuDT+JDN/63RS9Hcz1yr4YB
YVYLnI4hnFqnAMvx68xbjWXW3O8jEWIsgkMGcGa+giYIDV0bFaOCAUgwggFEMA4G
A1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUOIuA
oH2TmkY/ULd1symjM3KMmVswHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4Y
ZD8wJQYDVR0RAQH/BBswGYEXc2Fzb2FraXJhNjExNEBnbWFpbC5jb20wKQYKKwYB
BAGDvzABAQQbaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tMIGKBgorBgEEAdZ5
AgQCBHwEegB4AHYACGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3IAAAGD
TibPoAAABAMARzBFAiBBYUxFpEdyvAakCjn+oeSVjXHACO5cy3wbdnG1yla+8wIh
AP/M0EOBYQeM0UC/PBbgkhH2qY3VZbRPoV/ZSLDre7uVMAoGCCqGSM49BAMDA2kA
MGYCMQDpGJ4wxnLlNKT1lzpbwCdUAmcftAoZohq62z7NkmTgXJqET8VdiMCTagO4
4Jkb4GICMQDz0lHSw5SYgc1JF2RP0yScTIlkQ2Io4SG8efZwtJw77vlNhAYxBMx5
RX2TKy4AeO0=
-----END CERTIFICATE-----
"""

リクエストのin-toto attestationを検証してみる

次に、spec.content.envelope を見ていきます。 おさらいとしてcosiginのコマンドを貼っておきます。

$ cat test-result.json
{"passed": true}

$ COSIGN_EXPERIMENTAL=1 ./cosign attest  --type 'https://example.com/TestResult/v1' --predicate ./test-result.json otms61/test-custom-attest

spec.content.envelope にはin-toto attestationの形式で、指定した test-result.json の値がpredicateとして、対象のレポジトリ( otms61/test-custom-attest ) がsubjectとして入っています。

in-toto attestationについては以前に記事を書いたので参考にしてみてください。 otameshi61.hatenablog.com

$ cat envelop.intoto.jsonl | jq .
{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2V4YW1wbGUuY29tL1Rlc3RSZXN1bHQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiaW5kZXguZG9ja2VyLmlvL290bXM2MS90ZXN0LWN1c3RvbS1hdHRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiOTIyNTE0NTgwODhjNjM4MDYxY2RhOGZkOGI0MDNiNzZkNjYxYTRkYzZiN2VlNzFiNmFmZmNmMTg3MjU1N2IyYiJ9fV0sInByZWRpY2F0ZSI6eyJwYXNzZWQiOnRydWV9fQ==",
  "signatures": [
    {
      "keyid": "",
      "sig": "MEUCIAs21tx59kbSkqA9kRE/b3De51SN62cbJSCWG6x9PDO7AiEAquGbnjchwI4D0P4m4njMnH2AsnR/zpzosRogQugDkJ8="
    }
  ]
}

sigにある署名を検証してみます。 in-toto attestaionはDSSE Envelopeという形式をとっていて、payloadTypeとpayloadをもとにPAEという文字列を生成し、この文字列に対してsigを生成しています。

github.com

公開鍵は証明書から取り出したものを使用しています。

import base64
from cryptography.x509 import load_pem_x509_certificate

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes


cert = """-----BEGIN CERTIFICATE-----
MIICpDCCAimgAwIBAgIUOrq8rexC31ayBokWjaOX28PUdEYwCgYIKoZIzj0EAwMw
NzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRl
cm1lZGlhdGUwHhcNMjIwOTE4MDExMTQ5WhcNMjIwOTE4MDEyMTQ5WjAAMFkwEwYH
KoZIzj0CAQYIKoZIzj0DAQcDQgAEi2pKkJdJxrDZrCuDT+JDN/63RS9Hcz1yr4YB
YVYLnI4hnFqnAMvx68xbjWXW3O8jEWIsgkMGcGa+giYIDV0bFaOCAUgwggFEMA4G
A1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUOIuA
oH2TmkY/ULd1symjM3KMmVswHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4Y
ZD8wJQYDVR0RAQH/BBswGYEXc2Fzb2FraXJhNjExNEBnbWFpbC5jb20wKQYKKwYB
BAGDvzABAQQbaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tMIGKBgorBgEEAdZ5
AgQCBHwEegB4AHYACGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3IAAAGD
TibPoAAABAMARzBFAiBBYUxFpEdyvAakCjn+oeSVjXHACO5cy3wbdnG1yla+8wIh
AP/M0EOBYQeM0UC/PBbgkhH2qY3VZbRPoV/ZSLDre7uVMAoGCCqGSM49BAMDA2kA
MGYCMQDpGJ4wxnLlNKT1lzpbwCdUAmcftAoZohq62z7NkmTgXJqET8VdiMCTagO4
4Jkb4GICMQDz0lHSw5SYgc1JF2RP0yScTIlkQ2Io4SG8efZwtJw77vlNhAYxBMx5
RX2TKy4AeO0=
-----END CERTIFICATE-----"""


p = {
    "payloadType": "application/vnd.in-toto+json",
    "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2V4YW1wbGUuY29tL1Rlc3RSZXN1bHQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiaW5kZXguZG9ja2VyLmlvL290bXM2MS90ZXN0LWN1c3RvbS1hdHRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiOTIyNTE0NTgwODhjNjM4MDYxY2RhOGZkOGI0MDNiNzZkNjYxYTRkYzZiN2VlNzFiNmFmZmNmMTg3MjU1N2IyYiJ9fV0sInByZWRpY2F0ZSI6eyJwYXNzZWQiOnRydWV9fQ==",
    "signatures": [
        {
            "keyid": "",
            "sig": "MEUCIAs21tx59kbSkqA9kRE/b3De51SN62cbJSCWG6x9PDO7AiEAquGbnjchwI4D0P4m4njMnH2AsnR/zpzosRogQugDkJ8=",
        }
    ],
}


decoded = base64.b64decode(p["payload"]).decode("utf-8")
pae = f"DSSEv1 {len(p['payloadType'])} {p['payloadType']} {len(decoded)} {decoded}".encode(
    "utf-8"
)


z = load_pem_x509_certificate(cert.encode("utf-8"))
# 検証に失敗すると例外があがる
print(
    z.public_key().verify(
        signature=base64.b64decode(p["signatures"][0]["sig"]),
        data=pae,
        signature_algorithm=ec.ECDSA(hashes.SHA256()),
    )
)

"""
$ python z.py
None
"""

in-toto attestationのstatementのsubjectについて

payloadをデコードすると以下の情報が入っています。

$ cat envelop.intoto.jsonl | jq ".payload | @base64d | fromjson"
{
  "_type": "https://in-toto.io/Statement/v0.1",
  "predicateType": "https://example.com/TestResult/v1",
  "subject": [
    {
      "name": "index.docker.io/otms61/test-custom-attest",
      "digest": {
        "sha256": "92251458088c638061cda8fd8b403b76d661a4dc6b7ee71b6affcf1872557b2b"
      }
    }
  ],
  "predicate": {
    "passed": true
  }
}

predicate には --predicateで指定した test-result.json の内容が入っており、predicateTypeには --type で指定した https://example.com/TestResult/v1 が入っていることがわかります。

$ cat test-result.json
{"passed": true}

$ COSIGN_EXPERIMENTAL=1 ./cosign attest  --type 'https://example.com/TestResult/v1' --predicate ./test-result.json otms61/test-custom-attest

digest の sha256は、対象のレポジトリのマニフェストのsha256の値が入っています。

$ crane manifest otms61/test-custom-attest | shasum -a 256
92251458088c638061cda8fd8b403b76d661a4dc6b7ee71b6affcf1872557b2b  -

sigstoreがなぜsubjectに対照イメージのマニフェストハッシュ値を使っているかは以下のブログの 署名フォーマット に詳しいです。

knqyf263.hatenablog.com

リクエスト内のハッシュ値

残りのリクエストの値も見ていきます。

spec.content.hash の値は、 spec.content.envelopeハッシュ値です。

from hashlib import sha256

r = {
    "apiVersion": "0.0.1",
    "spec": {
        "content": {
            "envelope": '{"payloadType":"application/vnd.in-toto+json","payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2V4YW1wbGUuY29tL1Rlc3RSZXN1bHQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiaW5kZXguZG9ja2VyLmlvL290bXM2MS90ZXN0LWN1c3RvbS1hdHRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiOTIyNTE0NTgwODhjNjM4MDYxY2RhOGZkOGI0MDNiNzZkNjYxYTRkYzZiN2VlNzFiNmFmZmNmMTg3MjU1N2IyYiJ9fV0sInByZWRpY2F0ZSI6eyJwYXNzZWQiOnRydWV9fQ==","signatures":[{"keyid":"","sig":"MEUCIAs21tx59kbSkqA9kRE/b3De51SN62cbJSCWG6x9PDO7AiEAquGbnjchwI4D0P4m4njMnH2AsnR/zpzosRogQugDkJ8="}]}',
            "hash": {
                "algorithm": "sha256",
                "value": "505eaf34d3fd4b40b3d60734b1f14d1792d4dc91b0b2677993fd37b8d493a466",
            },
        },
        "publicKey": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNwRENDQWltZ0F3SUJBZ0lVT3JxOHJleEMzMWF5Qm9rV2phT1gyOFBVZEVZd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpJd09URTRNREV4TVRRNVdoY05Nakl3T1RFNE1ERXlNVFE1V2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVpMnBLa0pkSnhyRFpyQ3VEVCtKRE4vNjNSUzlIY3oxeXI0WUIKWVZZTG5JNGhuRnFuQU12eDY4eGJqV1hXM084akVXSXNna01HY0dhK2dpWUlEVjBiRmFPQ0FVZ3dnZ0ZFTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVPSXVBCm9IMlRta1kvVUxkMXN5bWpNM0tNbVZzd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d0pRWURWUjBSQVFIL0JCc3dHWUVYYzJGemIyRnJhWEpoTmpFeE5FQm5iV0ZwYkM1amIyMHdLUVlLS3dZQgpCQUdEdnpBQkFRUWJhSFIwY0hNNkx5OWhZMk52ZFc1MGN5NW5iMjluYkdVdVkyOXRNSUdLQmdvckJnRUVBZFo1CkFnUUNCSHdFZWdCNEFIWUFDR0NTOENoUy8yaEYwZEZySjRTY1JXY1lyQlk5d3pqU2JlYThJZ1kyYjNJQUFBR0QKVGliUG9BQUFCQU1BUnpCRkFpQkJZVXhGcEVkeXZBYWtDam4rb2VTVmpYSEFDTzVjeTN3YmRuRzF5bGErOHdJaApBUC9NMEVPQllRZU0wVUMvUEJiZ2toSDJxWTNWWmJSUG9WL1pTTERyZTd1Vk1Bb0dDQ3FHU000OUJBTURBMmtBCk1HWUNNUURwR0o0d3huTGxOS1QxbHpwYndDZFVBbWNmdEFvWm9ocTYyejdOa21UZ1hKcUVUOFZkaU1DVGFnTzQKNEprYjRHSUNNUUR6MGxIU3c1U1lnYzFKRjJSUDB5U2NUSWxrUTJJbzRTRzhlZlp3dEp3Nzd2bE5oQVl4Qk14NQpSWDJUS3k0QWVPMD0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=",
    },
    "kind": "intoto",
}

hash = sha256(r["spec"]["content"]["envelope"].encode("utf-8"))
print(hash.hexdigest() == r["spec"]["content"]["hash"]["value"])

"""
> python a.py
True
"""

レスポンス

< HTTP/2.0 201 Created
< Content-Type: application/json
< Date: Sun, 18 Sep 2022 01:11:53 GMT
< Location: /api/v1/log/entries/4c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1
< Strict-Transport-Security: max-age=15724800; includeSubDomains
< Vary: Origin

{
    "4c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1": {
        "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaW50b3RvIiwic3BlYyI6eyJjb250ZW50Ijp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI1MDVlYWYzNGQzZmQ0YjQwYjNkNjA3MzRiMWYxNGQxNzkyZDRkYzkxYjBiMjY3Nzk5M2ZkMzdiOGQ0OTNhNDY2In0sInBheWxvYWRIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZDRjZjI2YWUwZmQ3OWRhMTU2ZjAzYzgwNjY2ZTFjZWFmZTljNjQ4ZWU2MGQwYjM2ODBlNGU4ZTQzZDNiZGNiMCJ9fSwicHVibGljS2V5IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTndSRU5EUVdsdFowRjNTVUpCWjBsVlQzSnhPSEpsZUVNek1XRjVRbTlyVjJwaFQxZ3lPRkJWWkVWWmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEpkMDlVUlRSTlJFVjRUVlJSTlZkb1kwNU5ha2wzVDFSRk5FMUVSWGxOVkZFMVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZwTW5CTGEwcGtTbmh5UkZweVEzVkVWQ3RLUkU0dk5qTlNVemxJWTNveGVYSTBXVUlLV1ZaWlRHNUpOR2h1Um5GdVFVMTJlRFk0ZUdKcVYxaFhNMDg0YWtWWFNYTm5hMDFIWTBkaEsyZHBXVWxFVmpCaVJtRlBRMEZWWjNkblowWkZUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZQU1hWQkNtOUlNbFJ0YTFrdlZVeGtNWE41YldwTk0wdE5iVlp6ZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBwUldVUldVakJTUVZGSUwwSkNjM2RIV1VWWVl6SkdlbUl5Um5KaFdFcG9UbXBGZUU1RlFtNWlWMFp3WWtNMWFtSXlNSGRMVVZsTFMzZFpRZ3BDUVVkRWRucEJRa0ZSVVdKaFNGSXdZMGhOTmt4NU9XaFpNazUyWkZjMU1HTjVOVzVpTWpsdVlrZFZkVmt5T1hSTlNVZExRbWR2Y2tKblJVVkJaRm8xQ2tGblVVTkNTSGRGWldkQ05FRklXVUZEUjBOVE9FTm9VeTh5YUVZd1pFWnlTalJUWTFKWFkxbHlRbGs1ZDNwcVUySmxZVGhKWjFreVlqTkpRVUZCUjBRS1ZHbGlVRzlCUVVGQ1FVMUJVbnBDUmtGcFFrSlpWWGhHY0VWa2VYWkJZV3REYW00cmIyVlRWbXBZU0VGRFR6VmplVE4zWW1SdVJ6RjViR0VyT0hkSmFBcEJVQzlOTUVWUFFsbFJaVTB3VlVNdlVFSmlaMnRvU0RKeFdUTldXbUpTVUc5V0wxcFRURVJ5WlRkMVZrMUJiMGREUTNGSFUwMDBPVUpCVFVSQk1tdEJDazFIV1VOTlVVUndSMG8wZDNodVRHeE9TMVF4Ykhwd1luZERaRlZCYldObWRFRnZXbTlvY1RZeWVqZE9hMjFVWjFoS2NVVlVPRlprYVUxRFZHRm5UelFLTkVwcllqUkhTVU5OVVVSNk1HeElVM2MxVTFsbll6RktSakpTVURCNVUyTlVTV3hyVVRKSmJ6UlRSemhsWmxwM2RFcDNOemQyYkU1b1FWbDRRazE0TlFwU1dESlVTM2swUVdWUE1EMEtMUzB0TFMxRlRrUWdRMFZTVkVsR1NVTkJWRVV0TFMwdExRbz0ifX0=",
        "integratedTime": 1663463513,
        "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
        "logIndex": 3527741,
        "verification": {
            "signedEntryTimestamp": "MEUCIQDDPOjpA4pKVf0go1NclpptEBALyqqmtJlhKi13w5CyxgIgB4lxM6RktSfMtoaeO8DffoN51ymoAZWAmJjvL6UrNz0="
        }
    }
}

ステータスコード201 Created で、Locationに作成されたリソースのパスが記載されています。 GETで取得した値の方が情報が多いため、こちらの値を見ていきます。

$ curl -s --request GET --url https://rekor.sigstore.dev/api/v1/log/entries/4c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1 | jq .
{
  "4c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1": {
    "attestation": {
      "data": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2V4YW1wbGUuY29tL1Rlc3RSZXN1bHQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiaW5kZXguZG9ja2VyLmlvL290bXM2MS90ZXN0LWN1c3RvbS1hdHRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiOTIyNTE0NTgwODhjNjM4MDYxY2RhOGZkOGI0MDNiNzZkNjYxYTRkYzZiN2VlNzFiNmFmZmNmMTg3MjU1N2IyYiJ9fV0sInByZWRpY2F0ZSI6eyJwYXNzZWQiOnRydWV9fQ=="
    },
    "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaW50b3RvIiwic3BlYyI6eyJjb250ZW50Ijp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI1MDVlYWYzNGQzZmQ0YjQwYjNkNjA3MzRiMWYxNGQxNzkyZDRkYzkxYjBiMjY3Nzk5M2ZkMzdiOGQ0OTNhNDY2In0sInBheWxvYWRIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZDRjZjI2YWUwZmQ3OWRhMTU2ZjAzYzgwNjY2ZTFjZWFmZTljNjQ4ZWU2MGQwYjM2ODBlNGU4ZTQzZDNiZGNiMCJ9fSwicHVibGljS2V5IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTndSRU5EUVdsdFowRjNTVUpCWjBsVlQzSnhPSEpsZUVNek1XRjVRbTlyVjJwaFQxZ3lPRkJWWkVWWmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEpkMDlVUlRSTlJFVjRUVlJSTlZkb1kwNU5ha2wzVDFSRk5FMUVSWGxOVkZFMVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZwTW5CTGEwcGtTbmh5UkZweVEzVkVWQ3RLUkU0dk5qTlNVemxJWTNveGVYSTBXVUlLV1ZaWlRHNUpOR2h1Um5GdVFVMTJlRFk0ZUdKcVYxaFhNMDg0YWtWWFNYTm5hMDFIWTBkaEsyZHBXVWxFVmpCaVJtRlBRMEZWWjNkblowWkZUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZQU1hWQkNtOUlNbFJ0YTFrdlZVeGtNWE41YldwTk0wdE5iVlp6ZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBwUldVUldVakJTUVZGSUwwSkNjM2RIV1VWWVl6SkdlbUl5Um5KaFdFcG9UbXBGZUU1RlFtNWlWMFp3WWtNMWFtSXlNSGRMVVZsTFMzZFpRZ3BDUVVkRWRucEJRa0ZSVVdKaFNGSXdZMGhOTmt4NU9XaFpNazUyWkZjMU1HTjVOVzVpTWpsdVlrZFZkVmt5T1hSTlNVZExRbWR2Y2tKblJVVkJaRm8xQ2tGblVVTkNTSGRGWldkQ05FRklXVUZEUjBOVE9FTm9VeTh5YUVZd1pFWnlTalJUWTFKWFkxbHlRbGs1ZDNwcVUySmxZVGhKWjFreVlqTkpRVUZCUjBRS1ZHbGlVRzlCUVVGQ1FVMUJVbnBDUmtGcFFrSlpWWGhHY0VWa2VYWkJZV3REYW00cmIyVlRWbXBZU0VGRFR6VmplVE4zWW1SdVJ6RjViR0VyT0hkSmFBcEJVQzlOTUVWUFFsbFJaVTB3VlVNdlVFSmlaMnRvU0RKeFdUTldXbUpTVUc5V0wxcFRURVJ5WlRkMVZrMUJiMGREUTNGSFUwMDBPVUpCVFVSQk1tdEJDazFIV1VOTlVVUndSMG8wZDNodVRHeE9TMVF4Ykhwd1luZERaRlZCYldObWRFRnZXbTlvY1RZeWVqZE9hMjFVWjFoS2NVVlVPRlprYVUxRFZHRm5UelFLTkVwcllqUkhTVU5OVVVSNk1HeElVM2MxVTFsbll6RktSakpTVURCNVUyTlVTV3hyVVRKSmJ6UlRSemhsWmxwM2RFcDNOemQyYkU1b1FWbDRRazE0TlFwU1dESlVTM2swUVdWUE1EMEtMUzB0TFMxRlRrUWdRMFZTVkVsR1NVTkJWRVV0TFMwdExRbz0ifX0=",
    "integratedTime": 1663463513,
    "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
    "logIndex": 3527741,
    "verification": {
      "inclusionProof": {
        "hashes": [
          "c9758c506f78253208cfb417acf808db1c7f845bc7d84426041be88da141de78",
          "f1f4310c743e29c84c5d0668eb710d1b6647a11d3b4ecc080105eac8941ed858",
          "7ad323f32d80ac4800c4d305e97e4082ada7545c7ce18e7e760f2a254fc37545",
          "7900dfe673792ae932e9003fc6219f7608413ae36e2ea8cd82522324dae23d7d",
          "f97d5b3cec12ff3607a3b25fa51e36c6fbb13553ec93d5cdc1ff3cdf509e17a6",
          "19cdf066e43cdde3b8782e2177b02b47891bae7b523238064c12a3e576f359bd",
          "ae95a5a30dc3f4f4aaa324f7f7d028a61b4c752efc2c5e680e4cb2b4bc2c616d",
          "5de43cdc0fc444eff6cdec0f8e36ff3dc02cd672fc03e4a06b51cf2022d2e575",
          "c9509918f560d8d4a7c688a236af36fde5e15e21e1548cf130c98695810970b8",
          "cdf24d1833eeef52de4c31b84a2f481930e6c2167ca1c9820e5311dc01287fd5",
          "81e8c50dd54ea567e80fc4427d61d26a9ad16e29cdaea6ac87e69aaed32a54f4",
          "fe79312498d289bcb6bc0d4226b21cabb71bb260f987bb711b548a5fe1f83b6e",
          "b06b1025bd35f834f3c39786ef4e554bf06c76f61e497fd1765d0689480d3107",
          "bca0b68bd01bcbedb77d5ef7760d79854383eb1359263884cee78eb7955766f9",
          "7ae973cb7963f4c8609ee238ba06d9d84f1f73be8aa145279e2110e59c7862a3",
          "f8b4461eb78dadbd7cffd9a281185b7b1f2dfee61c17688016dc75c40b728eb2",
          "4e659e217261878d89a86f48b5a2f1a8bf2e47aad22d077ad45d0bcec969c383",
          "72ffc75e39e3042b31f3ee5e6f6315726d1263c7ccc6465bfca688cc2f612247",
          "6d494b237648126525b08f975c736a55d1f7a64472fcc2782bbc16733c608d7b",
          "efb36cfc54705d8cd921a621a9389ffa03956b15d68bfabadac2b4853852079b"
        ],
        "logIndex": 3527741,
        "rootHash": "5f1fbe1b5cb5b9daff35778d78ef02a2f11584539a59c62835afac3ef3198da1",
        "treeSize": 3531728
      },
      "signedEntryTimestamp": "MEYCIQCLfobPJEVNQ7//arswv62/R1PgQMAelCl5zHa+tr0dFwIhAMGNKi77UKqKrFeTeFlaLQzPs5pTwFf6+uHCj56fn1TB"
    }
  }
}

まず、1番上の 4c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1 はrekorの中でUUIDと呼ばれるIDで、エントリに一意に紐づくID。また、logIndex の3527741も同様にエントリに紐づく値になっています。

logIDは、rekorの公開鍵のハッシュ値となっています。logIDという名前はSCTの公開鍵のハッシュ値の時に使われていた名称から持ってきていると思われますが、rekorのレスポンスに並べられると、エントリのindexのlogIndexなどがありかなり分かりにくいですね。。

# TUFから取得
> curl -s https://sigstore-tuf-root.storage.googleapis.com/targets/rekor.pub | openssl ec -outform der -pubin | shasum -a 256
read EC key
writing EC key
c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d  -

# rekorから取得
> curl -s --request GET  --url https://rekor.sigstore.dev/api/v1/log/publicKey  | openssl ec -outform der -pubin | shasum -a 256
read EC key
writing EC key
c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d  -

attestation.data には、アップロードしたattestaionのpayloadが入っています。

bodyの中身

body は デコードすると以下の情報が入っています。

{
  "apiVersion": "0.0.1",
  "kind": "intoto",
  "spec": {
    "content": {
      "hash": {
        "algorithm": "sha256",
        "value": "505eaf34d3fd4b40b3d60734b1f14d1792d4dc91b0b2677993fd37b8d493a466"
      },
      "payloadHash": {
        "algorithm": "sha256",
        "value": "d4cf26ae0fd79da156f03c80666e1ceafe9c648ee60d0b3680e4e8e43d3bdcb0"
      }
    },
    "publicKey": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNwRENDQWltZ0F3SUJBZ0lVT3JxOHJleEMzMWF5Qm9rV2phT1gyOFBVZEVZd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpJd09URTRNREV4TVRRNVdoY05Nakl3T1RFNE1ERXlNVFE1V2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVpMnBLa0pkSnhyRFpyQ3VEVCtKRE4vNjNSUzlIY3oxeXI0WUIKWVZZTG5JNGhuRnFuQU12eDY4eGJqV1hXM084akVXSXNna01HY0dhK2dpWUlEVjBiRmFPQ0FVZ3dnZ0ZFTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVPSXVBCm9IMlRta1kvVUxkMXN5bWpNM0tNbVZzd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d0pRWURWUjBSQVFIL0JCc3dHWUVYYzJGemIyRnJhWEpoTmpFeE5FQm5iV0ZwYkM1amIyMHdLUVlLS3dZQgpCQUdEdnpBQkFRUWJhSFIwY0hNNkx5OWhZMk52ZFc1MGN5NW5iMjluYkdVdVkyOXRNSUdLQmdvckJnRUVBZFo1CkFnUUNCSHdFZWdCNEFIWUFDR0NTOENoUy8yaEYwZEZySjRTY1JXY1lyQlk5d3pqU2JlYThJZ1kyYjNJQUFBR0QKVGliUG9BQUFCQU1BUnpCRkFpQkJZVXhGcEVkeXZBYWtDam4rb2VTVmpYSEFDTzVjeTN3YmRuRzF5bGErOHdJaApBUC9NMEVPQllRZU0wVUMvUEJiZ2toSDJxWTNWWmJSUG9WL1pTTERyZTd1Vk1Bb0dDQ3FHU000OUJBTURBMmtBCk1HWUNNUURwR0o0d3huTGxOS1QxbHpwYndDZFVBbWNmdEFvWm9ocTYyejdOa21UZ1hKcUVUOFZkaU1DVGFnTzQKNEprYjRHSUNNUUR6MGxIU3c1U1lnYzFKRjJSUDB5U2NUSWxrUTJJbzRTRzhlZlp3dEp3Nzd2bE5oQVl4Qk14NQpSWDJUS3k0QWVPMD0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo="
  }
}

spec.content.hash には、リクエストでも送った in-toto attestationのハッシュ値が入っています。 spec.publicKey には、リクエストでも送った fulcioから受け取ったユーザの証明書が含まれています。

spec.content.payloadHash には、attestation.data をデコードした値のsha256が入っています。

import base64
import json
import hashlib

r = {
    "4c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1": {
        "attestation": {
            "data": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2V4YW1wbGUuY29tL1Rlc3RSZXN1bHQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiaW5kZXguZG9ja2VyLmlvL290bXM2MS90ZXN0LWN1c3RvbS1hdHRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiOTIyNTE0NTgwODhjNjM4MDYxY2RhOGZkOGI0MDNiNzZkNjYxYTRkYzZiN2VlNzFiNmFmZmNmMTg3MjU1N2IyYiJ9fV0sInByZWRpY2F0ZSI6eyJwYXNzZWQiOnRydWV9fQ=="
        },
        "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaW50b3RvIiwic3BlYyI6eyJjb250ZW50Ijp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI1MDVlYWYzNGQzZmQ0YjQwYjNkNjA3MzRiMWYxNGQxNzkyZDRkYzkxYjBiMjY3Nzk5M2ZkMzdiOGQ0OTNhNDY2In0sInBheWxvYWRIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZDRjZjI2YWUwZmQ3OWRhMTU2ZjAzYzgwNjY2ZTFjZWFmZTljNjQ4ZWU2MGQwYjM2ODBlNGU4ZTQzZDNiZGNiMCJ9fSwicHVibGljS2V5IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTndSRU5EUVdsdFowRjNTVUpCWjBsVlQzSnhPSEpsZUVNek1XRjVRbTlyVjJwaFQxZ3lPRkJWWkVWWmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEpkMDlVUlRSTlJFVjRUVlJSTlZkb1kwNU5ha2wzVDFSRk5FMUVSWGxOVkZFMVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZwTW5CTGEwcGtTbmh5UkZweVEzVkVWQ3RLUkU0dk5qTlNVemxJWTNveGVYSTBXVUlLV1ZaWlRHNUpOR2h1Um5GdVFVMTJlRFk0ZUdKcVYxaFhNMDg0YWtWWFNYTm5hMDFIWTBkaEsyZHBXVWxFVmpCaVJtRlBRMEZWWjNkblowWkZUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZQU1hWQkNtOUlNbFJ0YTFrdlZVeGtNWE41YldwTk0wdE5iVlp6ZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBwUldVUldVakJTUVZGSUwwSkNjM2RIV1VWWVl6SkdlbUl5Um5KaFdFcG9UbXBGZUU1RlFtNWlWMFp3WWtNMWFtSXlNSGRMVVZsTFMzZFpRZ3BDUVVkRWRucEJRa0ZSVVdKaFNGSXdZMGhOTmt4NU9XaFpNazUyWkZjMU1HTjVOVzVpTWpsdVlrZFZkVmt5T1hSTlNVZExRbWR2Y2tKblJVVkJaRm8xQ2tGblVVTkNTSGRGWldkQ05FRklXVUZEUjBOVE9FTm9VeTh5YUVZd1pFWnlTalJUWTFKWFkxbHlRbGs1ZDNwcVUySmxZVGhKWjFreVlqTkpRVUZCUjBRS1ZHbGlVRzlCUVVGQ1FVMUJVbnBDUmtGcFFrSlpWWGhHY0VWa2VYWkJZV3REYW00cmIyVlRWbXBZU0VGRFR6VmplVE4zWW1SdVJ6RjViR0VyT0hkSmFBcEJVQzlOTUVWUFFsbFJaVTB3VlVNdlVFSmlaMnRvU0RKeFdUTldXbUpTVUc5V0wxcFRURVJ5WlRkMVZrMUJiMGREUTNGSFUwMDBPVUpCVFVSQk1tdEJDazFIV1VOTlVVUndSMG8wZDNodVRHeE9TMVF4Ykhwd1luZERaRlZCYldObWRFRnZXbTlvY1RZeWVqZE9hMjFVWjFoS2NVVlVPRlprYVUxRFZHRm5UelFLTkVwcllqUkhTVU5OVVVSNk1HeElVM2MxVTFsbll6RktSakpTVURCNVUyTlVTV3hyVVRKSmJ6UlRSemhsWmxwM2RFcDNOemQyYkU1b1FWbDRRazE0TlFwU1dESlVTM2swUVdWUE1EMEtMUzB0TFMxRlRrUWdRMFZTVkVsR1NVTkJWRVV0TFMwdExRbz0ifX0=",
        "integratedTime": 1663463513,
        "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
        "logIndex": 3527741,
        "verification": {
            "inclusionProof": {
                "hashes": [
                    "c9758c506f78253208cfb417acf808db1c7f845bc7d84426041be88da141de78",
                    "f1f4310c743e29c84c5d0668eb710d1b6647a11d3b4ecc080105eac8941ed858",
                    "7ad323f32d80ac4800c4d305e97e4082ada7545c7ce18e7e760f2a254fc37545",
                    "7900dfe673792ae932e9003fc6219f7608413ae36e2ea8cd82522324dae23d7d",
                    "f97d5b3cec12ff3607a3b25fa51e36c6fbb13553ec93d5cdc1ff3cdf509e17a6",
                    "19cdf066e43cdde3b8782e2177b02b47891bae7b523238064c12a3e576f359bd",
                    "ae95a5a30dc3f4f4aaa324f7f7d028a61b4c752efc2c5e680e4cb2b4bc2c616d",
                    "5de43cdc0fc444eff6cdec0f8e36ff3dc02cd672fc03e4a06b51cf2022d2e575",
                    "c9509918f560d8d4a7c688a236af36fde5e15e21e1548cf130c98695810970b8",
                    "cdf24d1833eeef52de4c31b84a2f481930e6c2167ca1c9820e5311dc01287fd5",
                    "81e8c50dd54ea567e80fc4427d61d26a9ad16e29cdaea6ac87e69aaed32a54f4",
                    "fe79312498d289bcb6bc0d4226b21cabb71bb260f987bb711b548a5fe1f83b6e",
                    "b06b1025bd35f834f3c39786ef4e554bf06c76f61e497fd1765d0689480d3107",
                    "bca0b68bd01bcbedb77d5ef7760d79854383eb1359263884cee78eb7955766f9",
                    "7ae973cb7963f4c8609ee238ba06d9d84f1f73be8aa145279e2110e59c7862a3",
                    "f8b4461eb78dadbd7cffd9a281185b7b1f2dfee61c17688016dc75c40b728eb2",
                    "4e659e217261878d89a86f48b5a2f1a8bf2e47aad22d077ad45d0bcec969c383",
                    "72ffc75e39e3042b31f3ee5e6f6315726d1263c7ccc6465bfca688cc2f612247",
                    "6d494b237648126525b08f975c736a55d1f7a64472fcc2782bbc16733c608d7b",
                    "efb36cfc54705d8cd921a621a9389ffa03956b15d68bfabadac2b4853852079b",
                ],
                "logIndex": 3527741,
                "rootHash": "5f1fbe1b5cb5b9daff35778d78ef02a2f11584539a59c62835afac3ef3198da1",
                "treeSize": 3531728,
            },
            "signedEntryTimestamp": "MEYCIQCLfobPJEVNQ7//arswv62/R1PgQMAelCl5zHa+tr0dFwIhAMGNKi77UKqKrFeTeFlaLQzPs5pTwFf6+uHCj56fn1TB",
        },
    }
}

hashed = hashlib.sha256(
    base64.b64decode(
        r["4c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1"][
            "attestation"
        ]["data"].encode("utf-8")
    )
).hexdigest()
body = json.loads(
    base64.b64decode(
        r["4c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1"]["body"]
    ).decode("utf-8")
)
print(hashed == body["spec"]["content"]["payloadHash"]["value"])

"""
> python c.py
True
"""

verification について - inclusionProof

verification.inclusionProof は、trillian のデータ構造の値が埋め込んであるようですが、rekor のクライアントの検証にも使われていないため、どう活用するかはまだ確認中です

2022/10/3 追記 @knqyf263 さんに VerifyTLogEntryの中でinclusionProofが使われていることを教えていただきました。ありがとうございます!

cosign/tlog.go at b3b6ae25362dc2c92c78abf2370ba0342ee86b2f · sigstore/cosign · GitHub

inclusionProof は、trillianが使っているデータ構造のMerkle Treeの検証に必要な情報が含まれています。 検証の流れを理解するには以下のブログを読んでもらうのが1番良いと思います。

transparency.dev

簡単に流れを説明すると、rekorのレスポンスに含まれるbodyから得られる値(下図の②)からleaf(B)を計算し、BとAからCを計算します。その後にCとDからrootのEまで計算します。

https://transparency.dev/images/merkle-trees/merkle-tree-a-b-d.svg

https://transparency.dev/images/merkle-trees/merkle-tree-a-b-d.svg

ここで、AとDについては今まで全く出てこない値となります。rekorでは、これらの値はサーバのレスポンスから受け取ります。

したがって、rekorのレスポンスに含まれるbodyと、inclusionProofのhashesの値からrootのハッシュまで計算していきます。

rootのハッシュは別途inclusionProofにrootHashとして与えられます。また、このrootのハッシュ値はrekorに署名された形で取得も可能のようです。

以下のようなコードでrekorからのレスポンスからrootハッシュを計算することが可能です。

import base64
import hashlib


res = {
    "362f8ecba72f43264c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1": {
        "attestation": {
            "data": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2V4YW1wbGUuY29tL1Rlc3RSZXN1bHQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiaW5kZXguZG9ja2VyLmlvL290bXM2MS90ZXN0LWN1c3RvbS1hdHRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiOTIyNTE0NTgwODhjNjM4MDYxY2RhOGZkOGI0MDNiNzZkNjYxYTRkYzZiN2VlNzFiNmFmZmNmMTg3MjU1N2IyYiJ9fV0sInByZWRpY2F0ZSI6eyJwYXNzZWQiOnRydWV9fQ=="
        },
        "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaW50b3RvIiwic3BlYyI6eyJjb250ZW50Ijp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI1MDVlYWYzNGQzZmQ0YjQwYjNkNjA3MzRiMWYxNGQxNzkyZDRkYzkxYjBiMjY3Nzk5M2ZkMzdiOGQ0OTNhNDY2In0sInBheWxvYWRIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZDRjZjI2YWUwZmQ3OWRhMTU2ZjAzYzgwNjY2ZTFjZWFmZTljNjQ4ZWU2MGQwYjM2ODBlNGU4ZTQzZDNiZGNiMCJ9fSwicHVibGljS2V5IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTndSRU5EUVdsdFowRjNTVUpCWjBsVlQzSnhPSEpsZUVNek1XRjVRbTlyVjJwaFQxZ3lPRkJWWkVWWmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEpkMDlVUlRSTlJFVjRUVlJSTlZkb1kwNU5ha2wzVDFSRk5FMUVSWGxOVkZFMVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZwTW5CTGEwcGtTbmh5UkZweVEzVkVWQ3RLUkU0dk5qTlNVemxJWTNveGVYSTBXVUlLV1ZaWlRHNUpOR2h1Um5GdVFVMTJlRFk0ZUdKcVYxaFhNMDg0YWtWWFNYTm5hMDFIWTBkaEsyZHBXVWxFVmpCaVJtRlBRMEZWWjNkblowWkZUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZQU1hWQkNtOUlNbFJ0YTFrdlZVeGtNWE41YldwTk0wdE5iVlp6ZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBwUldVUldVakJTUVZGSUwwSkNjM2RIV1VWWVl6SkdlbUl5Um5KaFdFcG9UbXBGZUU1RlFtNWlWMFp3WWtNMWFtSXlNSGRMVVZsTFMzZFpRZ3BDUVVkRWRucEJRa0ZSVVdKaFNGSXdZMGhOTmt4NU9XaFpNazUyWkZjMU1HTjVOVzVpTWpsdVlrZFZkVmt5T1hSTlNVZExRbWR2Y2tKblJVVkJaRm8xQ2tGblVVTkNTSGRGWldkQ05FRklXVUZEUjBOVE9FTm9VeTh5YUVZd1pFWnlTalJUWTFKWFkxbHlRbGs1ZDNwcVUySmxZVGhKWjFreVlqTkpRVUZCUjBRS1ZHbGlVRzlCUVVGQ1FVMUJVbnBDUmtGcFFrSlpWWGhHY0VWa2VYWkJZV3REYW00cmIyVlRWbXBZU0VGRFR6VmplVE4zWW1SdVJ6RjViR0VyT0hkSmFBcEJVQzlOTUVWUFFsbFJaVTB3VlVNdlVFSmlaMnRvU0RKeFdUTldXbUpTVUc5V0wxcFRURVJ5WlRkMVZrMUJiMGREUTNGSFUwMDBPVUpCVFVSQk1tdEJDazFIV1VOTlVVUndSMG8wZDNodVRHeE9TMVF4Ykhwd1luZERaRlZCYldObWRFRnZXbTlvY1RZeWVqZE9hMjFVWjFoS2NVVlVPRlprYVUxRFZHRm5UelFLTkVwcllqUkhTVU5OVVVSNk1HeElVM2MxVTFsbll6RktSakpTVURCNVUyTlVTV3hyVVRKSmJ6UlRSemhsWmxwM2RFcDNOemQyYkU1b1FWbDRRazE0TlFwU1dESlVTM2swUVdWUE1EMEtMUzB0TFMxRlRrUWdRMFZTVkVsR1NVTkJWRVV0TFMwdExRbz0ifX0=",
        "integratedTime": 1663463513,
        "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
        "logIndex": 3527741,
        "verification": {
            "inclusionProof": {
                "checkpoint": "rekor.sigstore.dev - 3904496407287907110\n4163431\nTQBqpG78tgfdUdkAsSE3VMUMySUcNAXGwlYdnWovMjk=\nTimestamp: 1664726649849955098\n\n— rekor.sigstore.dev wNI9ajBFAiEA2fSWL7E94qWhPUcYBULuLtRWe7gFy0yyIConYkLkp6wCIHQ6ObwfZPcn28sCLe9DoDb7mdVXAo/a5qtX5q5N1JIR\n",
                "hashes": [
                    "c9758c506f78253208cfb417acf808db1c7f845bc7d84426041be88da141de78",
                    "f1f4310c743e29c84c5d0668eb710d1b6647a11d3b4ecc080105eac8941ed858",
                    "7ad323f32d80ac4800c4d305e97e4082ada7545c7ce18e7e760f2a254fc37545",
                    "7900dfe673792ae932e9003fc6219f7608413ae36e2ea8cd82522324dae23d7d",
                    "f97d5b3cec12ff3607a3b25fa51e36c6fbb13553ec93d5cdc1ff3cdf509e17a6",
                    "19cdf066e43cdde3b8782e2177b02b47891bae7b523238064c12a3e576f359bd",
                    "ae95a5a30dc3f4f4aaa324f7f7d028a61b4c752efc2c5e680e4cb2b4bc2c616d",
                    "5de43cdc0fc444eff6cdec0f8e36ff3dc02cd672fc03e4a06b51cf2022d2e575",
                    "c9509918f560d8d4a7c688a236af36fde5e15e21e1548cf130c98695810970b8",
                    "cdf24d1833eeef52de4c31b84a2f481930e6c2167ca1c9820e5311dc01287fd5",
                    "81e8c50dd54ea567e80fc4427d61d26a9ad16e29cdaea6ac87e69aaed32a54f4",
                    "fe79312498d289bcb6bc0d4226b21cabb71bb260f987bb711b548a5fe1f83b6e",
                    "b06b1025bd35f834f3c39786ef4e554bf06c76f61e497fd1765d0689480d3107",
                    "628304d1503a60c71421b080a8e63f9f412e5434084dbfb66dfdef1a785e2034",
                    "7ae973cb7963f4c8609ee238ba06d9d84f1f73be8aa145279e2110e59c7862a3",
                    "f8b4461eb78dadbd7cffd9a281185b7b1f2dfee61c17688016dc75c40b728eb2",
                    "4e659e217261878d89a86f48b5a2f1a8bf2e47aad22d077ad45d0bcec969c383",
                    "3e7b39f35274294419372997dc29033a0cdb7c1f628a44df62eadcaa9a487e35",
                    "72ffc75e39e3042b31f3ee5e6f6315726d1263c7ccc6465bfca688cc2f612247",
                    "b387d94cf186d8be69e18e15422d3c85a6146d6527a8653c0fa07423d45d2dcb",
                    "6d494b237648126525b08f975c736a55d1f7a64472fcc2782bbc16733c608d7b",
                    "efb36cfc54705d8cd921a621a9389ffa03956b15d68bfabadac2b4853852079b",
                ],
                "logIndex": 3527741,
                "rootHash": "4d006aa46efcb607dd51d900b1213754c50cc9251c3405c6c2561d9d6a2f3239",
                "treeSize": 4163431,
            },
            "signedEntryTimestamp": "MEUCICqfXqsxO8GiismNP4xykravxTrmGBF550HELnzDBumcAiEAl8FeAWT3Hk7BBx6Dgol/Uzy+GaLP347Djf3jM7dxQSs=",
        },
    }
}
r = res[
    "362f8ecba72f43264c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1"
]


leaf = hashlib.sha256(b"\x00" + base64.b64decode(r["body"])).hexdigest()
proof = r["verification"]["inclusionProof"]
index = proof["logIndex"]
size = proof["treeSize"]

inner = len(bin(index ^ (size - 1)).lstrip("0b"))
border = len(bin(index >> inner).lstrip("0b"))


def hash_children(l, r):
    return hashlib.sha256(b"\x01" + bytes.fromhex(l) + bytes.fromhex(r)).hexdigest()


seed = leaf

for i, h in enumerate(proof["hashes"][:inner]):
    if (index >> i) & 1 == 0:
        seed = hash_children(seed, h)
    else:
        seed = hash_children(h, seed)

for i, h in enumerate(proof["hashes"][inner:]):
    seed = hash_children(h, seed)

print(seed == proof["rootHash"])

"""
> python x.py
True
"""

verification について - signedEntryTimestamp

verification.signedEntryTimestamp を見ていきます。

"signedEntryTimestamp": "MEYCIQCLfobPJEVNQ7//arswv62/R1PgQMAelCl5zHa+tr0dFwIhAMGNKi77UKqKrFeTeFlaLQzPs5pTwFf6+uHCj56fn1TB",

これはレスポンスに含まれる integratedTime, logIndex, body, logID をrekorが署名した値になっています。 検証に使うための鍵はAPI経由で取得することができます。

> curl --request GET  --url https://rekor.sigstore.dev/api/v1/log/publicKey

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwr
kBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==
-----END PUBLIC KEY-----

データは JSON Canonicalization Scheme という、jsonの形式が一意に定まるようにフォーマットを使っています。 datatracker.ietf.org

import base64
import hashlib

import ecdsa
from ecdsa.util import sigdecode_der
import canonicaljson


res = {
    "4c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1": {
        "attestation": {
            "data": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2V4YW1wbGUuY29tL1Rlc3RSZXN1bHQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiaW5kZXguZG9ja2VyLmlvL290bXM2MS90ZXN0LWN1c3RvbS1hdHRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiOTIyNTE0NTgwODhjNjM4MDYxY2RhOGZkOGI0MDNiNzZkNjYxYTRkYzZiN2VlNzFiNmFmZmNmMTg3MjU1N2IyYiJ9fV0sInByZWRpY2F0ZSI6eyJwYXNzZWQiOnRydWV9fQ=="
        },
        "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaW50b3RvIiwic3BlYyI6eyJjb250ZW50Ijp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI1MDVlYWYzNGQzZmQ0YjQwYjNkNjA3MzRiMWYxNGQxNzkyZDRkYzkxYjBiMjY3Nzk5M2ZkMzdiOGQ0OTNhNDY2In0sInBheWxvYWRIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZDRjZjI2YWUwZmQ3OWRhMTU2ZjAzYzgwNjY2ZTFjZWFmZTljNjQ4ZWU2MGQwYjM2ODBlNGU4ZTQzZDNiZGNiMCJ9fSwicHVibGljS2V5IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTndSRU5EUVdsdFowRjNTVUpCWjBsVlQzSnhPSEpsZUVNek1XRjVRbTlyVjJwaFQxZ3lPRkJWWkVWWmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEpkMDlVUlRSTlJFVjRUVlJSTlZkb1kwNU5ha2wzVDFSRk5FMUVSWGxOVkZFMVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZwTW5CTGEwcGtTbmh5UkZweVEzVkVWQ3RLUkU0dk5qTlNVemxJWTNveGVYSTBXVUlLV1ZaWlRHNUpOR2h1Um5GdVFVMTJlRFk0ZUdKcVYxaFhNMDg0YWtWWFNYTm5hMDFIWTBkaEsyZHBXVWxFVmpCaVJtRlBRMEZWWjNkblowWkZUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZQU1hWQkNtOUlNbFJ0YTFrdlZVeGtNWE41YldwTk0wdE5iVlp6ZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBwUldVUldVakJTUVZGSUwwSkNjM2RIV1VWWVl6SkdlbUl5Um5KaFdFcG9UbXBGZUU1RlFtNWlWMFp3WWtNMWFtSXlNSGRMVVZsTFMzZFpRZ3BDUVVkRWRucEJRa0ZSVVdKaFNGSXdZMGhOTmt4NU9XaFpNazUyWkZjMU1HTjVOVzVpTWpsdVlrZFZkVmt5T1hSTlNVZExRbWR2Y2tKblJVVkJaRm8xQ2tGblVVTkNTSGRGWldkQ05FRklXVUZEUjBOVE9FTm9VeTh5YUVZd1pFWnlTalJUWTFKWFkxbHlRbGs1ZDNwcVUySmxZVGhKWjFreVlqTkpRVUZCUjBRS1ZHbGlVRzlCUVVGQ1FVMUJVbnBDUmtGcFFrSlpWWGhHY0VWa2VYWkJZV3REYW00cmIyVlRWbXBZU0VGRFR6VmplVE4zWW1SdVJ6RjViR0VyT0hkSmFBcEJVQzlOTUVWUFFsbFJaVTB3VlVNdlVFSmlaMnRvU0RKeFdUTldXbUpTVUc5V0wxcFRURVJ5WlRkMVZrMUJiMGREUTNGSFUwMDBPVUpCVFVSQk1tdEJDazFIV1VOTlVVUndSMG8wZDNodVRHeE9TMVF4Ykhwd1luZERaRlZCYldObWRFRnZXbTlvY1RZeWVqZE9hMjFVWjFoS2NVVlVPRlprYVUxRFZHRm5UelFLTkVwcllqUkhTVU5OVVVSNk1HeElVM2MxVTFsbll6RktSakpTVURCNVUyTlVTV3hyVVRKSmJ6UlRSemhsWmxwM2RFcDNOemQyYkU1b1FWbDRRazE0TlFwU1dESlVTM2swUVdWUE1EMEtMUzB0TFMxRlRrUWdRMFZTVkVsR1NVTkJWRVV0TFMwdExRbz0ifX0=",
        "integratedTime": 1663463513,
        "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
        "logIndex": 3527741,
        "verification": {
            "inclusionProof": {
                "hashes": [
                    "c9758c506f78253208cfb417acf808db1c7f845bc7d84426041be88da141de78",
                    "f1f4310c743e29c84c5d0668eb710d1b6647a11d3b4ecc080105eac8941ed858",
                    "7ad323f32d80ac4800c4d305e97e4082ada7545c7ce18e7e760f2a254fc37545",
                    "7900dfe673792ae932e9003fc6219f7608413ae36e2ea8cd82522324dae23d7d",
                    "f97d5b3cec12ff3607a3b25fa51e36c6fbb13553ec93d5cdc1ff3cdf509e17a6",
                    "19cdf066e43cdde3b8782e2177b02b47891bae7b523238064c12a3e576f359bd",
                    "ae95a5a30dc3f4f4aaa324f7f7d028a61b4c752efc2c5e680e4cb2b4bc2c616d",
                    "5de43cdc0fc444eff6cdec0f8e36ff3dc02cd672fc03e4a06b51cf2022d2e575",
                    "c9509918f560d8d4a7c688a236af36fde5e15e21e1548cf130c98695810970b8",
                    "cdf24d1833eeef52de4c31b84a2f481930e6c2167ca1c9820e5311dc01287fd5",
                    "81e8c50dd54ea567e80fc4427d61d26a9ad16e29cdaea6ac87e69aaed32a54f4",
                    "fe79312498d289bcb6bc0d4226b21cabb71bb260f987bb711b548a5fe1f83b6e",
                    "b06b1025bd35f834f3c39786ef4e554bf06c76f61e497fd1765d0689480d3107",
                    "bca0b68bd01bcbedb77d5ef7760d79854383eb1359263884cee78eb7955766f9",
                    "7ae973cb7963f4c8609ee238ba06d9d84f1f73be8aa145279e2110e59c7862a3",
                    "f8b4461eb78dadbd7cffd9a281185b7b1f2dfee61c17688016dc75c40b728eb2",
                    "4e659e217261878d89a86f48b5a2f1a8bf2e47aad22d077ad45d0bcec969c383",
                    "72ffc75e39e3042b31f3ee5e6f6315726d1263c7ccc6465bfca688cc2f612247",
                    "6d494b237648126525b08f975c736a55d1f7a64472fcc2782bbc16733c608d7b",
                    "efb36cfc54705d8cd921a621a9389ffa03956b15d68bfabadac2b4853852079b",
                ],
                "logIndex": 3527741,
                "rootHash": "5f1fbe1b5cb5b9daff35778d78ef02a2f11584539a59c62835afac3ef3198da1",
                "treeSize": 3531728,
            },
            "signedEntryTimestamp": "MEYCIQCLfobPJEVNQ7//arswv62/R1PgQMAelCl5zHa+tr0dFwIhAMGNKi77UKqKrFeTeFlaLQzPs5pTwFf6+uHCj56fn1TB",
        },
    }
}
r = res["4c43de72686ceb257f6641ca1d62069f7f891822bca772d166dd88941e31c5f1"]

pub = """-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwr
kBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==
-----END PUBLIC KEY-----"""


data = canonicaljson.encode_canonical_json(
    {
        "integratedTime": r["integratedTime"],
        "logIndex": r["logIndex"],
        "body": r["body"],
        "logID": r["logID"],
    }
)

v = ecdsa.VerifyingKey.from_pem(pub)
print(
    v.verify(
        signature=base64.b64decode(r["verification"]["signedEntryTimestamp"]),
        data=data,
        hashfunc=hashlib.sha256,
        sigdecode=sigdecode_der,
    )
)


"""
> python d.py
True
"""

verification.signedEntryTimestamp の検証には integratedTime, logIndex, body, logID を使っているため、attestationのデータの attestation.data は直接含まれていません。ただし、spec.content.payloadHash には attestation.data をデコードした値のsha256が入っています。そのため、verification.signedEntryTimestamp の検証後に、spec.content.payloadHash を確認することで、attestation.data が改ざんされていないことがわかります。

最後に

ひとつのコマンドでやり取りされる内容をまとめていたらだいぶ長くなってしまった。リクエスト内でhashやsignatureなどのキーで値がやり取りされているが、一体どこの値のハッシュを取ったのかや、どこの値をどこの鍵でsignatureを作っているのかなど曖昧な点が多かった。今回記事を書きながら手を動かせたことで、だいぶ理解が深まったと思う。

sigstore関連の話題は、関連する技術も同時に知ることができて結構楽しい。SCTの検証とinclusionProofは調べきれなかったのは心残りではある。

Golangのjson.Unmarshalとjson.Decoder.Decodeの違い

Golang標準のjsonパッケージでは、jsonをstructに展開する方法として、 json.Unmarshal という関数と、 json.NewDecoderデコーダを生成し、 Decode 関数を呼ぶ方法がある。

インターフェイスは、

  • func Unmarshal(data []byte, v any) error
  • func NewDecoder(r io.Reader) *Decoderfunc (dec *Decoder) Decode(v any) error

バイト列を渡すのと、io.Readerから渡すという点が異なるが、これによる挙動の違いが最初わかっていなかった。

違いの結論

  • Unmarshalに渡すバイト列はひとつのjsonとして正しい形式である必要がある
  • Decoderにio.Readerを渡してDecodeする方法は、Decodeはストリームから次のjsonを取り出して処理するため、jsonが複数個含まれているファイルも処理できる

{ "example": "1" } の処理

以下のような { "example": "1" } をただ処理したい場合には同じ挙動をしてくれる。

json.Unmarshalを使う方法

package main

import (
    "encoding/json"
    "fmt"
)

type Example struct {
    Example string `json:"example"`
}

func main() {
    jsonSrc := []byte(`{ "example": "1" }`)

    var myJson Example
    if err := json.Unmarshal(jsonSrc, &myJson); err != nil {
        panic(err)
    }
    fmt.Printf("%+v\n", myJson)
}
$ go run main.go 
{Example:1}

json.Decoder.Decodeを使う場合

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
)

type Example struct {
    Example string `json:"example"`
}

func main() {
    jsonSrc := []byte(`{ "example": "1" }`)

    var myJson Example
    decoder := json.NewDecoder(bytes.NewReader(jsonSrc))
    decoder.Decode(&myJson)
    fmt.Printf("%+v\n", myJson)
}
> go run main.go
{Example:1}

複数行のjson処理

次に、2行のjsonを処理してみる。

{ "example": "1" }
{ "example": "2" }

この場合、json.Unmarshalはパースに失敗し、json.Decoder.Decodeは処理することができる。

json.Unmarshalを使う場合 - 複数行を処理

package main

import (
    "encoding/json"
    "fmt"
)

type Example struct {
    Example string `json:"example"`
}

func main() {
    jsonSrc := []byte(`{ "example": "1" }
  { "example": "2" }`)

    var myJson Example
    if err := json.Unmarshal(jsonSrc, &myJson); err != nil {
        panic(err)
    }
    fmt.Printf("%+v\n", myJson)
}

$ go run main.go
panic: invalid character '{' after top-level value

json.Decoder.Decodeを使う場合 - 複数行を処理

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
)

type Example struct {
    Example string `json:"example"`
}

func main() {
    jsonSrc := []byte(`{ "example": "1" }
  { "example": "2" }`)

    var myJson Example
    decoder := json.NewDecoder(bytes.NewReader(jsonSrc))
    decoder.Decode(&myJson)
    fmt.Printf("%+v\n", myJson)
}
> go run main.go
{Example:1}

関数の説明を読むとわかるが、入力のリーダーから次のJSONを取り出して、処理してくれるということがわかる。

Decode reads the next JSON-encoded value from its input and stores it in the value pointed to by v.

従って繰り返し呼ぶことで、2行目も取り出すことができる。

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
)

type Example struct {
    Example string `json:"example"`
}

func main() {
    jsonSrc := []byte(`{ "example": "1" }
  { "example": "2" }`)

    var myJson Example
    decoder := json.NewDecoder(bytes.NewReader(jsonSrc))
    for {
        if err := decoder.Decode(&myJson); err != nil {
            fmt.Println("error: ", err)
            break
        }
        fmt.Printf("%+v\n", myJson)
    }
}
> go run main.go
{Example:1}
{Example:2}
error:  EOF

Decode関数を読みすすめたjsonのポインタを保持しており、次にDecode関数が呼ばれた場合には、そこからjsonを取り出している処理を確認できる。

github.com

感想

Decoderにはストリームを渡しているのだから、わかってしまえば、それはそうという挙動だと思う。ただ、連続したjsonを扱いたいというわけではなく os.File を便利に扱えるというモチベーションで使用していたので、2個のjsonが含まれるファイルでデコードのエラーにならなかった時には、ちょっとびっくりした。

in-toto attestation の仕様とcosignでの使われ方

はじめに

anchoreのsyftが最近対応したattestationの出力 に使われている in-toto attestation format の仕様を調査をしたので、備忘録としてまとめておきます。

調べながらの記述となっており、不正確な情報も含まれているかもしれないです。何か気づいたことがあればコメントやtwitterなどで教えていただけると助かります。

TL; DL

例えば、あるDockerImageの作者は {"author": "saso"} というメタデータのattestationを作りたい時に、

  • メタデータの型を http://my.example.com/author とする
  • メタデータ{"author": "saso"}
  • 対象のDockerImageのdigestは sha256:20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062
  • 署名はcosignで生成した鍵を使用する

の場合に生成される、attestaionは以下のようになります。

{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwOi8vbXkuZXhhbXBsZS5jb20vYXV0aG9yIiwic3ViamVjdCI6W3sibmFtZSI6ImluZGV4LmRvY2tlci5pby9vdG1zNjEvaGVsbG8tMSIsImRpZ2VzdCI6eyJzaGEyNTYiOiIyMGQzZjY5M2RjZmZhNDRkNmIyNGVhZTg4NzgzMzI0ZDI1Y2MxMzJjMjIwODlmNzBlNGZiZmI4NTg2MjViMDYyIn19XSwicHJlZGljYXRlIjp7ImF1dGhvciI6InNhc28ifX0=",
  "signatures": [
    {
      "keyid": "",
      "sig": "MEQCIG+na8kNMK4u9j2jc2Db94aR0rglNqbHZcscHH9QqP6zAiAtBfLFuNLhHNh/uPkaD++c7F1czPrCKdBdjq+If/g67Q=="
    }
  ]
}

payloadのbase64をデコードすると以下のようなデーが含まれます。

{
  "_type": "https://in-toto.io/Statement/v0.1",
  "predicateType": "http://my.example.com/author",
  "subject": [
    {
      "name": "index.docker.io/otms61/hello-1",
      "digest": {
        "sha256": "20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062"
      }
    }
  ],
  "predicate": {
    "author": "saso"
  }
}

in-toto attestation の概要

in-toto attestation format は、ソフトウェアのアーティファクトのセットに対するメタデータへの署名のフォーマットを提供します。

仕様は in-toto/attestation で定義されています。現在(2022/5/22)でのバージョンは 0.1.0 で、開発段階とのことです。

アーティファクトはハッシュで特定できる必要があり、複数のアーティファクトが同じメタデータを持つこともあります。 仕様の中で例としてあげられているものとしては、

などがあります。

構成要素

attestationの大まかな構成要素は、以下の3つです。

Envelope

Envelopeは、attestationの出力して得られるjsonの形式を定義しています。Evelopeの形式は Dead Simple Signing Envelope(DSSE) というフォーマットでもとにしています。 payloadType,payload,signatures の3つのキーをもちます。

Envelopeは以下のようなjson形式です。

{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "<Base64(Statement)>",
  "signatures": [{"sig": "<Base64(Signature)>"}]
}
  • payloadType は application/vnd.in-toto+json で固定
  • payload に次に紹介するStatementとPredicateがbase64エンコードされて入っている
  • signatureには DSSE て定義されている payloadTypeとpayloadに対するひとつ以上の署名
    • signatureはpayloadTypeとpayloadに対して署名を作る
    • 作り方は、 SIGNATURE = Sign(PAE(UTF8(payloadType), SERIALIZED_BODY))
    • Signは署名する関数
    • SERIALIZED_BODY は、payloadのbase64される前のもの。 payload=<Base64(SERIALIZED_BODY)>
    • PAEは PAE(payloadType, payload) = "DSSEv1" + SP + LEN(payloadType) + SP + payloadType + SP + LEN(SERIALIZED_BODY) + SP + SERIALIZED_BODY
      • payloadTypeが http://example.com/HelloWorld で、SERIALIZED_BODYが hello world (この時にpayloadは aGVsbG8gd29ybGQ= となる) の場合 PAEは、 DSSEv1 29 http://example.com/HelloWorld 11 hello world

Statement

Statementは対象のソフトウェアのアーティファクトを管理します。

StatementとPredicateは以下のようなjson形式です。Envelopeのpayloadに、このjsonbase64エンコードされて埋め込まれています。

{
  "_type": "https://in-toto.io/Statement/v0.1",
  "subject": [
    {
      "name": "<NAME>",
      "digest": {"<ALGORITHM>": "<HEX_VALUE>"}
    },
    ...
  ],
  "predicateType": "<URI>",
  "predicate": {
    // arbitary object
  }
}
  • _type はv0.1では https://in-toto.io/Statement/v0.1 で固定
  • subject は対象のアーティファクトのセット
  • subject[*].digest は アルゴリズムハッシュ値が入っている
    • subject[*].digest は DigestSet という型で定義されている。sha256以上の強度のハッシュアルゴリズムが推奨されている。
  • subject[*].name はアーティファクトの名前
  • ビルドされた環境」のメタデータをバイナリの形式で区別する場合は以下のようになる
    • { "name": "curl-7.72.0.zip", "digest": { "sha256": "e363cc5b4e500bfc727106434a2578b38440aa18e105d57576f3d8f2abebf888" }}
    • { "name": "curl-7.72.0.tar.gz", "digest": { "sha256": "d4d5899a3868fbb6ae1856c3e55a32ce35913de3956d1973caccd37bd0174fa2" }}

Predicate

Prdicateはメタデータの型とメタデータの実体を持ちます。

  • predicateType はPredicateの型を定義しているURI
    • TypeURIRFC3986 のフォーマット
    • predicateTypeの例
      • SLSA によって定義されているアーティファクトのどのように生成されたかを記述する predicateType は https://slsa.dev/provenance/v0.1
      • anchoreでcyclonedx-jsonのフォーマットでattestの出力で使われているpredicateTypeは、 https://cyclonedx.org/bom
      • cosign の定義する脆弱性情報のattestのpredicateTypeは cosign.sigstore.dev/attestation/vuln/v1となっている
        • RFC3986の URIのBFN を見る感じでも、URIにはschemaの指定が必須なので、このpredicateTypeはshemaを持たないのであまりよくないようにはみえる。
  • predicate はpredicateTypeで定義されたデータが入る
    • syftのSBOMのattest生成の場合にはSBOMのデータそのままpredicateに含まれている

attestation の具体例

githubに上がっている例です。一部省略しています。

{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "ewogICJzdWJqZWN0IjogWwogICAg...",
  "signatures": [{"sig": "MeQyap6MyFyc9Y..."}]
}

payloadをbase64でデコードしたものは以下になります。

{
  "_type": "https://in-toto.io/Statement/v0.1",
  "subject": [
    { "name": "curl-7.72.0.tar.gz",
      "digest": { "sha256": "d4d5899a3868fbb6ae1856c3e55a32ce35913de3956d1973caccd37bd0174fa2" }},
    { "name": "curl-7.72.0.zip",
      "digest": { "sha256": "e363cc5b4e500bfc727106434a2578b38440aa18e105d57576f3d8f2abebf888" }}
  ],
  "predicateType": "https://slsa.dev/provenance/v0.1",
  "predicate": {
    "builder": { "id": "https://github.com/Attestations/GitHubHostedActions@v1" },
    "recipe": {
      "type": "https://github.com/Attestations/GitHubActionsWorkflow@v1",
      "definedInMaterial": 0,
      "entryPoint": "build.yaml:maketgz"
    },
    "metadata": {
      "buildStartedOn": "2020-08-19T08:38:00Z"
    },
    "materials": [
      {
        "uri": "git+https://github.com/curl/curl-docker@master",
        "digest": { "sha1": "d6525c840a62b398424a78d792f457477135d0cf" }
      }, {
        "uri": "github_hosted_vm:ubuntu-18.04:20210123.1"
      }, {
        "uri": "git+https://github.com/actions/checkout@v2",
        "digest": {"sha1": "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"}
      }, {
        "uri": "git+https://github.com/actions/upload-artifact@v2",
        "digest": { "sha1": "e448a9b857ee2131e752b06002bf0e093c65e571" }
      }, {
        "uri": "pkg:deb/debian/stunnel4@5.50-3?arch=amd64",
        "digest": { "sha256": "e1731ae217fcbc64d4c00d707dcead45c828c5f762bcf8cc56d87de511e096fa" }
      }, {
        "uri": "pkg:deb/debian/libbrotli-dev@1.0.7-2+deb10u1?arch=amd64",
        "digest": { "sha256": "05b6e467173c451b6211945de47ac0eda2a3dccb3cc7203e800c633f74de8b4f" }
      }
    ]
  }
}

cosign のattestation

cosign を使ってDocker Imageに対してattestationを作成し、DockerHubのようなOCI Registryにattestationを保存することができます。

試しに適当なpredicateTypeとpredicateを自分で作成したotms61/hello-1登録してみます。

$ cosign generate-key-pair
Enter password for private key:
Enter password for private key again:
Private key written to cosign.key
Public key written to cosign.pub

$ cat predicate.json |jq .
{
  "author": "saso"
}

$ cosign attest  --key cosign.key --type http://my.example.com/author --predicate predicate.json otms61/hello-1

verify-attestationコマンドで生成されたattestationの確認と、署名が正しいかの確認ができます。

$ cosign verify-attestation --key cosign.pub otms61/hello-1

Verification for otms61/hello-1 --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key
{"payloadType":"application/vnd.in-toto+json","payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwOi8vbXkuZXhhbXBsZS5jb20vYXV0aG9yIiwic3ViamVjdCI6W3sibmFtZSI6ImluZGV4LmRvY2tlci5pby9vdG1zNjEvaGVsbG8tMSIsImRpZ2VzdCI6eyJzaGEyNTYiOiIyMGQzZjY5M2RjZmZhNDRkNmIyNGVhZTg4NzgzMzI0ZDI1Y2MxMzJjMjIwODlmNzBlNGZiZmI4NTg2MjViMDYyIn19XSwicHJlZGljYXRlIjp7ImF1dGhvciI6InNhc28ifX0=","signatures":[{"keyid":"","sig":"MEQCIG+na8kNMK4u9j2jc2Db94aR0rglNqbHZcscHH9QqP6zAiAtBfLFuNLhHNh/uPkaD++c7F1czPrCKdBdjq+If/g67Q=="}]}

最後の1行に生成されたattestationが出力されています。payloadのbase64をデコードすると登録したpredicateが確認できます。

$ pbpaste | jq '.payload | @base64d | fromjson'
{
  "_type": "https://in-toto.io/Statement/v0.1",
  "predicateType": "http://my.example.com/author",
  "subject": [
    {
      "name": "index.docker.io/otms61/hello-1",
      "digest": {
        "sha256": "20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062"
      }
    }
  ],
  "predicate": {
    "author": "saso"
  }
}

間違った鍵で公開鍵で検証をすると以下のような出力になります。

$ cosign verify-attestation --key cosign.pub otms61/hello-1
Error: no matching attestations:
Accepted signatures do not match threshold, Found: 0, Expected 1
main.go:46: error during command execution: no matching attestations:
Accepted signatures do not match threshold, Found: 0, Expected 1

OCI Registryにどう保管されるか

cosign attestコマンドはattestationを生成して、DockerHubの対象イメージに ${イメージのダイジェスト}.att というタグでアーティファクトは保存されています。

$ crane digest otms61/hello-1:latest
sha256:20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062

$ crane ls otms61/hello-1
latest
sha256-20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062.att

今回の場合は イメージのダイジェストが sha256:20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062 なので、 sha256-20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062.att というタグでアーティファクトが保存されています。

マニフェストを確認すると以下の通りです。

> crane manifest otms61/hello-1:sha256-20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062.att | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": 233,
    "digest": "sha256:e06a8292130008eb64770bafe3b2c6d2e22f0b5e0b2125ee95f131c61e719a64"
  },
  "layers": [
    {
      "mediaType": "application/vnd.dsse.envelope.v1+json",
      "size": 544,
      "digest": "sha256:60f9b1fbaf99f54daee933b6a8920b0dde05f5d8dd7b5b7e466f23b2f96f3cb4",
      "annotations": {
        "dev.cosignproject.cosign/signature": ""
      }
    }
  ]
}

configの中身 (crane blob otms61/hello-1@sha256:e06a8292130008eb64770bafe3b2c6d2e22f0b5e0b2125ee95f131c61e719a64の結果)

{
  "architecture": "",
  "created": "0001-01-01T00:00:00Z",
  "history": [
    {
      "created": "0001-01-01T00:00:00Z"
    }
  ],
  "os": "",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:60f9b1fbaf99f54daee933b6a8920b0dde05f5d8dd7b5b7e466f23b2f96f3cb4"
    ]
  },
  "config": {}
}

layerの中身( crane blob otms61/hello-1@sha256:60f9b1fbaf99f54daee933b6a8920b0dde05f5d8dd7b5b7e466f23b2f96f3cb4 の結果)は、 cosign verify-attestation --key cosign.pub otms61/hello-1 コマンドの時に出力されていたものと同じものが取得できることがわかります。

{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwOi8vbXkuZXhhbXBsZS5jb20vYXV0aG9yIiwic3ViamVjdCI6W3sibmFtZSI6ImluZGV4LmRvY2tlci5pby9vdG1zNjEvaGVsbG8tMSIsImRpZ2VzdCI6eyJzaGEyNTYiOiIyMGQzZjY5M2RjZmZhNDRkNmIyNGVhZTg4NzgzMzI0ZDI1Y2MxMzJjMjIwODlmNzBlNGZiZmI4NTg2MjViMDYyIn19XSwicHJlZGljYXRlIjp7ImF1dGhvciI6InNhc28ifX0=",
  "signatures": [
    {
      "keyid": "",
      "sig": "MEQCIG+na8kNMK4u9j2jc2Db94aR0rglNqbHZcscHH9QqP6zAiAtBfLFuNLhHNh/uPkaD++c7F1czPrCKdBdjq+If/g67Q=="
    }
  ]
}

payloadの中身のbaes64を解いてみると。

> crane blob otms61/hello-1@sha256:60f9b1fbaf99f54daee933b6a8920b0dde05f5d8dd7b5b7e466f23b2f96f3cb4| jq -r '.payload | @base64d' | jq .
{
  "_type": "https://in-toto.io/Statement/v0.1",
  "predicateType": "http://my.example.com/author",
  "subject": [
    {
      "name": "index.docker.io/otms61/hello-1",
      "digest": {
        "sha256": "20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062"
      }
    }
  ],
  "predicate": {
    "author": "saso"
  }
}

subject に対象にしたDockerImageに関する情報が入っていて、 predicateTypeとpredicateに指定した情報が入っていることがわかります。

複数のattestationを保持した場合の挙動

cosign attest を使って複数登録した場合にはレイヤーが追加されます。

$ cat predicate.json
{"author": "saso", "github": "otms61"}

$ cosign attest  --key cosign.key --type http://my.example.com/author --predicate predicate.json otms61/hello-1
Enter password for private key:
Using payload from: predicate.json

manifestをみるとlayerが追加されていることがわかります。

$ crane manifest otms61/hello-1:sha256-20d3f693dcffa44d6b24eae88783324d25cc132c22089f70e4fbfb858625b062.att | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": 342,
    "digest": "sha256:10c33275bf4928e02c3364c1d9ee5c535ebf535995c808bcac9c1e628742acee"
  },
  "layers": [
    {
      "mediaType": "application/vnd.dsse.envelope.v1+json",
      "size": 544,
      "digest": "sha256:60f9b1fbaf99f54daee933b6a8920b0dde05f5d8dd7b5b7e466f23b2f96f3cb4",
      "annotations": {
        "dev.cosignproject.cosign/signature": ""
      }
    },
    {
      "mediaType": "application/vnd.dsse.envelope.v1+json",
      "size": 568,
      "digest": "sha256:640d3bf02710f135ad453d3ba8fcc1afd60395a0a15d93e3354291fa8e4b20c0",
      "annotations": {
        "dev.cosignproject.cosign/signature": ""
      }
    }
  ]
}

cosign verify-attestation の結果でも二種類のjsonが取得できていることがわかります。

$ cosign verify-attestation --key cosign.pub otms61/hello-1

Verification for otms61/hello-1 --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key
{"payloadType":"application/vnd.in-toto+json","payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwOi8vbXkuZXhhbXBsZS5jb20vYXV0aG9yIiwic3ViamVjdCI6W3sibmFtZSI6ImluZGV4LmRvY2tlci5pby9vdG1zNjEvaGVsbG8tMSIsImRpZ2VzdCI6eyJzaGEyNTYiOiIyMGQzZjY5M2RjZmZhNDRkNmIyNGVhZTg4NzgzMzI0ZDI1Y2MxMzJjMjIwODlmNzBlNGZiZmI4NTg2MjViMDYyIn19XSwicHJlZGljYXRlIjp7ImF1dGhvciI6InNhc28ifX0=","signatures":[{"keyid":"","sig":"MEQCIG+na8kNMK4u9j2jc2Db94aR0rglNqbHZcscHH9QqP6zAiAtBfLFuNLhHNh/uPkaD++c7F1czPrCKdBdjq+If/g67Q=="}]}
{"payloadType":"application/vnd.in-toto+json","payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwOi8vbXkuZXhhbXBsZS5jb20vYXV0aG9yIiwic3ViamVjdCI6W3sibmFtZSI6ImluZGV4LmRvY2tlci5pby9vdG1zNjEvaGVsbG8tMSIsImRpZ2VzdCI6eyJzaGEyNTYiOiIyMGQzZjY5M2RjZmZhNDRkNmIyNGVhZTg4NzgzMzI0ZDI1Y2MxMzJjMjIwODlmNzBlNGZiZmI4NTg2MjViMDYyIn19XSwicHJlZGljYXRlIjp7ImF1dGhvciI6InNhc28iLCJnaXRodWIiOiJvdG1zNjEifX0=","signatures":[{"keyid":"","sig":"MEUCIQDkqqPx6TTA383kBr5ZTQuAZfUKDBH7SS4eAyk8DRvasgIgMp9+dQMCzSBlV81tEATIGg/J1YGGLHOBE2atKhe91dk="}]}

新しく別の証明書を作成してattestを追加した時の挙動は確認してみたのですが、少し不可解な挙動をしているのでもう少し様子をみています。

syftのattestコマンド

alpine:3.15に対して cyclonedx-json のフォーマットでSBOMを作成し、attestationを作成しようとすると以下のようなコマンドになります。

$ syft attest --key ./cosign.key alpine:3.15 -o cyclonedx-json > sbom.att.json

sbom.att.jsonのpayloadをbase64でデコードすると以下のようなデータが入っています。

{
  "_type": "https://in-toto.io/Statement/v0.1",
  "predicateType": "https://cyclonedx.org/bom",
  "subject": [
    {
      "name": "",
      "digest": {
        "sha256": "4edbd2beb5f78b1014028f4fbb99f3237d9561100b6881aabbf5acce2c4f9454"
      }
    }
  ],
  "predicate": {
    "bomFormat": "CycloneDX",
    // 省略
  }
}

subjectのハッシュはalpine:3.15のダイジェストになっています。

$ crane digest alpine:3.15
sha256:4edbd2beb5f78b1014028f4fbb99f3237d9561100b6881aabbf5acce2c4f9454

predicateも含めすべて確認したい場合にはgistに公開鍵も含めてあげています。

alpine:3.15 sbom attestation · GitHub

syftにはverifyする機能は含まれておらず、cosignコマンドもOCI Registryに保管されているattestを検証する機能しかないようなので、attestationと公開鍵のセットがあっても簡単に検証できるようなコマンドは現状なさそうな雰囲気はあります。 syftでは生成したattestationをcosignコマンドでOCIに登録して、 cosign verify-attestation をする例が記載されています。 github.com

おわりに

in-toto/attestation の仕様と、cosignとsyftでの使われ方をざっとみてみました。 最初にattestationの形式を見た時に、署名ということで、jwtに近い話なのかなと感じていました。in-toto/attestation は、predicateTypeを明示しなくてはいけなかったり、複数のsubjectを持つことができたり、複数のsignatureを持つことができたり、jwtに比べるとより幅広いユースケースを想定しているように感じました。

また、subjectが対象のアーティファクトのダイジェストだと分からなかったり、署名方法やpredicateあたりがわかりにくく感じました。

cosignが対応したことにより、今後いろいろと使われていく可能性などなど感じられて興味深かったです。

ローカルでhttpsサーバの立て方

コールバックなどの関係で今使っているドメインをそのまま使って、手元の変更をすぐに確認したい場合などに、ローカルでhttpsサーバをたてて静的なサイトを用意したいことがありました。Mac OSGoogle Chromeでの確認の仕方を備忘録として必要なコマンドを残しておきます。

今回は https://my.example.com/ に手元の環境のファイルをホスティングする方法を記載します。

主に参考にした以下のサイトと実行しているコマンドはほぼ同じです。大事なことも多く書かれているので一度読んでみることをお勧めします。

web.dev

mkcertのインストール

mkcert はローカルでの開発用に証明書の発行できるツールです。

brew install mkcert
mkcert -install 

証明書の発行

$ mkcert my.example.com

Created a new certificate valid for the following names 📜
 - "my.example.com"
The certificate is at "./my.example.com.pem" and the key at "./my.example.com-key.pem" ✅
It will expire on 25 August 2023 🗓

$ ls
my.example.com-key.pem  my.example.com.pem

注意: ここで生成されたmy.example.com-key.pem と my.example.com.pemは他人には共有しないようにしてください。

http-serverのインストール

npm install -g http-server

http-serverの起動

$ mkdir src
$ cd src
$ echo "<h1>Hello World!</h1>" > index.html
$ http-server --ssl --port 443 --cert ../my.example.com.pem --key ../my.example.com-key.pem
Starting up http-server, serving ./ through https
Available on:
  https://127.0.0.1:443
  https://192.168.11.2:443
Hit CTRL-C to stop the server

/etc/hostsの更新

my.example.comのアクセスを127.0.0.1に向けるために、お好きなエディタで/etc/hostsに以下の1行を追加(sudo vim /etc/hostsなど)。root権限は必要です。

127.0.0.1 my.example.com

注意: 作業がおわったら削除かコメントアウトしてください。消し忘れると、本当のIPの方にアクセスできなくなり、いろいろ事故るので気をつけてください。

ブラウザでアクセス

ブラウザで https://my.example.com/ にアクセスすることでファイルにアクセスできます。

f:id:otameshi61:20210525233324p:plain

以上!

とりあえずファイルにアクセスしたいだけなら、以上です。

その他

その他いくつかためしたことなどのメモです。

ルーティングしたい場合

SPAなどで基本的に特定のファイルを返したいけど、/static配下は静的なファイルを返したいみたいなときはこんな感じのコードを書けばいけます。

const express = require('express');
const path = require('path');
const port = 443;
var app = express();

var fs = require('fs');
var https = require('https');
var options = {
  key: fs.readFileSync('../my.example.com-key.pem'),
  cert: fs.readFileSync('../my.example.com.pem')
};
var server = https.createServer(options, app);

app.use('/static', express.static(path.join(__dirname, 'static')))

app.get('*', function (request, response) {
  response.sendFile(path.resolve(__dirname, 'index.html'));
});

server.listen(port);
console.log("server started on port " + port);

コードは以下のサイトを参考にしています。

Node.js Express で HTTPSを利用するパターン - Qiita

mkcertの証明書について

mkcert -installコマンドでは、ローカルのsystem trust storeに登録されたというログが出力されます。

$ mkcert -install
Created a new local CA 💥
Sudo password:
The local CA is now installed in the system trust store! ⚡️

キーチェインで確認すると以下のルート証明書が有効にされていることが確認できます。

f:id:otameshi61:20210526001237p:plain

以下のコマンドで生成した証明書はブラウザの証明書の確認のところから、ルート証明書とチェーンになっていることがわかります。

$ mkcert my.example.com

Created a new certificate valid for the following names 📜
 - "my.example.com"
The certificate is at "./my.example.com.pem" and the key at "./my.example.com-key.pem" ✅
It will expire on 25 August 2023 🗓

f:id:otameshi61:20210526003055p:plain

Chromeの--host-rules

以下のブログでも紹介されている--host-rulesを使うとhostsファイルのようにドメインとIPのマッピングを更新できるとのこと。実際に試してみたのですが、確かに宛先は変わったのですが証明書のエラーが出てしまって、今回のケースでは使えないようでした。あまりちゃんと原因を追っていないので詳しい人がいたら知りたいです。

blog.jxck.io

こんな感じのコマンで試してました。

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --host-rules="MAP my.example.com 127.0.0.1" --user-data-dir="/tmp/aaa" https://my.example.com

感想

opensslのコマンドを調べて証明書を用意して、次に必要になった時には記憶からなくなっているのでもう一度調べるみたいなことを繰り返していましたが、今は調べてみたらいろいろ便利なツールが出ていて驚きました。

途中でリンクを貼ったmaudnalsさんとjxckさんのブログはlocalhostというドメイン名の特異性などに触れられていたとても勉強になりました。

誰かのお役に立てたら幸いです。

AWSIoTPythonSDK のshadowGetで subscribeTimeoutException がおきた話

Greengrass上で実行されるアプリケーションの開発中に目にした、Device Shadowを取得の挙動について備忘録として記録に残しておきます。 AWS IoT CoreのMQTT topicsやDevice Shadowのことをある程度知っていると理解しやすいかと思います。

何かしら間違ったことを書いてしまっていたところがあればご指摘いただけると幸いです。

GreengrassとAWSIoTPythonについて

Greengrassは正式名称には「AWS IoT Greengrass」で、AWSが提供するIoT向けのサービスです。IoT機器上に AWS Lambdaをデプロイしたり、実行してりできます。

aws.amazon.com

Greengrassでは証明書を使ってAWSリソースの権限を制御することができます。 AWSIoTPythonというSDKを使うことで、Greengrassの証明書を使ってMQTTやMQTT over the WebSocket protocolでAWSのリソースにアクセスすることができます。

github.com

DeviceShadowの値を取得してみる

以下のコードではGreengrassの Device Shadow というデバイスと他のシステム(バックエンドサーバなど)でデータを共有するためのデータストアの値を取得しています。 データの取得はshadowGetという関数を用いていて、受け取った値をsrcCallback関数に引き渡すことでDeviceShadowの値を取得できます。 以下のコードは抜粋なので、コード全体を見たい場合は GISTの方 をご確認ください。

def shadowGet_callback(payload: str, response_status: str, token: str):
    print("Got shadow:")
    print(json.dumps(json.loads(payload), indent=2))


def main():
    CLIENT_ID = "test1_Core"
    client = create_mqtt_connection(client_id=CLIENT_ID)
    shadow_handler = build_shadow_handler(
        clientID=CLIENT_ID, awsIoTMQTTClient=client
    )

    shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)


if __name__ == "__main__":
    main()

実行するとこのような結果になります。

> python shadowGet.py
Got shadow:
{
  "state": {
    "desired": {
      "welcome": "aws-iot"
    },
    "reported": {
      "welcome": "aws-iot"
    }
  },
  "metadata": {
    "desired": {
      "welcome": {
        "timestamp": 1614564291
      }
    },
    "reported": {
      "welcome": {
        "timestamp": 1614564291
      }
    }
  },
  "version": 1,
  "timestamp": 1615016122,
  "clientToken": "95a4a071-1e17-4d2d-b3f8-4ebfa7f2d1a1"
}

DeviceShadowは、AWS Consoleで確認すると以下のようになっています。

f:id:otameshi61:20210306180118p:plain
DeviceShadowの画面

subscribeしてみる

AWS IoTではMQTTを利用してメッセージのパブリッシュとサブスクライブをすることができます。 docs.aws.amazon.com

以下のコードでは hello/world というトピックをサブスクライブして、メッセージを受け取ったら出力しています(全体は こちら )。

def main():
    CLIENT_ID = "test1_Core"
    client = create_mqtt_connection(client_id=CLIENT_ID)

    def subscribe_callback(client, userdata, message):
        print(f"Received a new message: {message.payload.decode('utf-8')}")

    client.subscribe(topic="hello/world", QoS=1, callback=subscribe_callback)

    while True:
        time.sleep(5)


if __name__ == "__main__":
    main()

ブラウザではAWS ConsoleにMQTTのテストするクライアントが提供されていて、 hello/worldのトピックにメッセージを発行しています。ターミナルの方でプログラムを実行し、メッセージを受け取れていることが確認できます。

f:id:otameshi61:20210307170529g:plain
MQTTのパブリッシュとサブスクライブの確認

実行に失敗するコード

ここからが本記事の本題なのですが、MQTTのサブスクライブのcallback関数の中で、shadowを取得しようとするとなぜか例外があがってしまうという問題がおきました。 具体的な問題が起きるコードは抜粋すると以下のような感じです。

def shadowGet_callback(payload: str, response_status: str, token: str):
    print(f"shadow: {json.loads(payload)}")


def main():
    CLIENT_ID = "test1_Core"
    client = create_mqtt_connection(client_id=CLIENT_ID)
    shadow_handler = build_shadow_handler(
        clientID=CLIENT_ID, awsIoTMQTTClient=client
    )

    def subscribe_callback(client, userdata, message):
        print(f"Received a new message: {message.payload.decode('utf-8')}")
        shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)

    client.subscribe(topic="hello/world", QoS=1, callback=subscribe_callback)

    while True:
        time.sleep(5)


if __name__ == "__main__":
    main()

このコードでhello/worldトピックにパブリッシュすると、callbackの最初のprintは実行された後に subscribeTimeoutException の例外があがります。

$ python fail_shadowGet.py
Received a new message: {
  "message": "Hello from AWS IoT console"
}
Subscribe timed out
Exception in thread Thread-1:
Traceback (most recent call last):
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/threading.py", line 926, in _bootstrap_inner
    self.run()
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/threading.py", line 870, in run
    self._target(*self._args, **self._kwargs)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 147, in _dispatch
    self._dispatch_one()
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 154, in _dispatch_one
    self._dispatch_methods[event_type](mid, data)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 237, in _dispatch_message
    message_callback(None, None, message)  # message_callback(client, userdata, message)
  File "fail_shadowGet.py", line 47, in subscribe_callback
    shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/shadow/deviceShadow.py", line 243, in shadowGet
    self._shadowManagerHandler.basicShadowSubscribe(self._shadowName, "get", self.generalCallback)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/shadow/shadowManager.py", line 70, in basicShadowSubscribe
    self._mqttCoreHandler.subscribe(currentShadowAction.getTopicAccept(), 0, srcCallback)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/mqtt_core.py", line 306, in subscribe
    raise subscribeTimeoutException()
AWSIoTPythonSDK.exception.AWSIoTExceptions.subscribeTimeoutException

subscribeTimeoutExceptionが発生してしまう原因について調査

以降では、subscribeTimeoutExceptionが発生してしまう原因について調査をしていきます。

調査1. subscribeTimeoutExceptionという例外クラスについて

subscribeTimeoutExceptionの名前からサブスクライブ時にタイムアウトが発生しているんやろうなぁと予想できますが、なぜタイムアウトが発生してしまうのか、どのような条件だと例外が発生するかなど、何かしら情報がないか探してみます。

まずこのクラスの定義を確認してみると、クラス定義ではoperationTimeoutExceptionを継承していることやmsgが設定されていることはわかりますが、原因の手がかりにつながりそうな情報はなさそうでした。

class subscribeTimeoutException(operationTimeoutException.operationTimeoutException):
    def __init__(self, msg="Subscribe Timeout"):
        self.message = msg

github.com

APIドキュメントのサイトが公開されており、検索ボックスもあったのでsubscribeTimeoutExceptionで検索をしてみましたが、残念ながら検索ヒット数は0件でした。

s3.amazonaws.com

subscribeTimeoutExceptionでGoogle検索をした場合に出てくる情報にもいくつか目をとおしてみましたが、権限周りの設定が失敗している場合など、今回調査しているようなケースではなさそうでした。

調査2. shadowGetについて

例外をあげている shadowGet関数の説明 をみてみます。

shadowGet(srcCallback, srcTimeout)
Description

Retrieve the device shadow JSON document from AWS IoT by publishing an empty JSON document to the corresponding shadow topics. 
Shadow response topics will be subscribed to receive responses from AWS IoT regarding the result of the get operation. 
Retrieved shadow JSON document will be available in the registered callback. 
If no response is received within the provided timeout, a timeout notification will be passed into the registered callback.

shadowGetではAWS IoTのトピックにパブリッシュとサブスクライブすることで値を取得していることがわかります。 MQTT経由でDevice Shadowの値を取得する方法は AWS側で用意されているトピックを利用します。 AWS側が用意しているトピックについては、詳細についてはドキュメントに記載されています。

docs.aws.amazon.com

今回の場合は $aws/things/test1_Core/shadow/get のトピックにパブリッシュすることで、 $aws/things/test1_Core/shadow/get/accepted$aws/things/test1_Core/shadow/get/rejected のトピックに値が流れてきます。

AWS ConsoleでMQTTのテストツールを使うことで、AWS IoTへのトピックのパブリッシュとサブスクライブを確認することができます。

以下の動画では、$aws/things/test1_Core/shadow/get/acceptedというトピックをサブスクライブした状態で $aws/things/test1_Core/shadow/get というトピックにパブリッシュをしています。 重要なことなのでもう一度書きますが、$aws/things/test1_Core/shadow/get/acceptedというトピックをまずサブスクライブした後に、$aws/things/test1_Core/shadow/get というトピックにパブリッシュをしています。この順番は大事で、後ほども触れます。 サブスクライブした状態でパブリッシュすると値は取得できますが、パブリッシュした後にサブスクライブしても値は取得することはできません。

$aws/things/test1_Core/shadow/get にパブリッシュした後に $aws/things/test1_Core/shadow/get/accepted に値がながれてきていることがわかります。

f:id:otameshi61:20210301193550g:plain
MQTT経由でdevice shadowを取得する

補足ですが、shadowGetの第二引数のsrcTimeoutは、$aws/things/test1_Core/shadow/get にトピックを発行して $aws/things/test1_Core/shadow/get/accepted が取得できるまでのタイムアウトです。この場合のタイムアウトでは、タイムアウトが起きたことを伝える情報を伴ってcallbackが呼ばれるため subscribeTimeoutException は発生しないので、今回調査しているタイムアウトとは関係ありません。

shadowGetでは、AWS IoTのトピックにパブリッシュとサブスクライブをすることでDevice Shadowの値を取得していることがわかりました。

調査3. shadowGetの実装について

shadowGetの実装をみてみます。 抜粋すると以下のような実装になっています。

    def shadowGet(self, srcCallback, srcTimeout):
        # (省略)

        # Two subscriptions
        if not self._isPersistentSubscribe or not self._isGetSubscribed:
            self._shadowManagerHandler.basicShadowSubscribe(self._shadowName, "get", self.generalCallback)
            self._isGetSubscribed = True
            self._logger.info("Subscribed to get accepted/rejected topics for deviceShadow: " + self._shadowName)
        # One publish
        self._shadowManagerHandler.basicShadowPublish(self._shadowName, "get", currentPayload)

        # (省略)

関数の実装をすべてみたい場合はソースをご確認ください。 github.com

コメントに「Two subscriptions」と「One publish」と書かれています。「 調査2. shadowGetについて」で確認した $aws/things/test1_Core/shadow/get/accepted$aws/things/test1_Core/shadow/get/rejected のサブスクライブと $aws/things/test1_Core/shadow/get のトピックにパブリッシュすることでDevice Shadowの値を取得していることと合致していることがわかります。

実装で気になる点としては、サブスクライブする時に not self._isPersistentSubscribe or not self._isGetSubscribed の条件が真の場合のみ実行されています。サブスクライブを永続化するオプションでクライアントを生成し、一度shadowGetを行うと最初の実行時のみサブスクライブがされ、以降では使いまわしているようです。

コードをみても調査2のAWS IoTのトピックにパブリッシュとサブスクライブをすることでDevice Shadowの値を取得しているということが確認できました。

調査4. subscribeTimeoutExceptionの例外を上げている箇所について

次にPythonのTracebackをみていきます。

Traceback (most recent call last):
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/threading.py", line 926, in _bootstrap_inner
    self.run()
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/threading.py", line 870, in run
    self._target(*self._args, **self._kwargs)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 147, in _dispatch
    self._dispatch_one()
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 154, in _dispatch_one
    self._dispatch_methods[event_type](mid, data)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 237, in _dispatch_message
    message_callback(None, None, message)  # message_callback(client, userdata, message)
  File "fail_shadowGet.py", line 47, in subscribe_callback
    shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/shadow/deviceShadow.py", line 243, in shadowGet
    self._shadowManagerHandler.basicShadowSubscribe(self._shadowName, "get", self.generalCallback)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/shadow/shadowManager.py", line 70, in basicShadowSubscribe
    self._mqttCoreHandler.subscribe(currentShadowAction.getTopicAccept(), 0, srcCallback)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/mqtt_core.py", line 306, in subscribe
    raise subscribeTimeoutException()
AWSIoTPythonSDK.exception.AWSIoTExceptions.subscribeTimeoutException

以下の部分から「調査3. shadowGetの実装について」でみたshadowGet関数の中のサブスクライブをしようとしている箇所で例外があがっていることがわかります。

  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/shadow/deviceShadow.py", line 243, in shadowGet
    self._shadowManagerHandler.basicShadowSubscribe(self._shadowName, "get", self.generalCallback)

実際に例外があげられているのはAWSIoTPythonSDK/core/protocol/mqtt_core.py の中のsubscribe関数の中となっています。

さて、ドキュメントの関数一覧をみてみると、AWSIoTPythonSDKでは、subscribesubscribeAsync の二通りのサブスクライブするインターフェイスが提供されていることがわかります。名前からある程度察することができますが、AWSIoTPythonではサブスクライブする時に同期的行うか、非同期的に行うかを選ぶことができます。

同期的なsubscribe関数では、関数の実行が完了した時にサブスクライブしていることが保証されますが、非同期的なsubscribeAsync関数ではサブスクライブしていることは保証されていません。

調査2と調査3で確認した通り、shadowGetではまずはトピックをサブスクライブした後に、パブリッシュをして値を取得しています。そのため、サブスクライブが完了していることが保証されるsubscribe関数を使用する必要があります。

getShadowは、トピックのサブスクライブをする時に、特にsbscribe関数を使っているということがわかります。

s3.amazonaws.com

調査5. READMEに記載されている同期的な呼び出しと非同期的な呼び出しについて

さて、次はREADMEに記載されている「Synchronous APIs and Asynchronous APIs」という部分をみてみます。

Synchronous APIs and Asynchronous APIs
Beginning with Release v1.2.0, SDK provides asynchronous APIs and enforces synchronous API behaviors for MQTT operations, which includes: - connect/connectAsync - disconnect/disconnectAsync - publish/publishAsync - subscribe/subscribeAsync - unsubscribe/unsubscribeAsync

github.com

関数の一覧の中には subscribe/subscirbeAsync などサブスクライブに関して同期的なAPIと非同期的なAPIがあることが示されています。

もう少し読み進めると、以下のような説明があります。

Since callbacks are sequentially dispatched and invoked, calling synchronous APIs within callbacks will deadlock the user application. 
If users are inclined to utilize the asynchronous mode and perform MQTT operations within callbacks, asynchronous APIs should be used. 

DeepLでの翻訳も載せておきます。

コールバックは順次ディスパッチされて呼び出されるので、コールバック内で同期APIを呼び出すと、ユーザアプリケーションはデッドロックしてしまいます。
ユーザーが非同期モードを利用してコールバック内でMQTT操作を行いたい場合は、非同期APIを使用する必要があります。

callback内で同期的なAPI呼び出しするとデッドロックを引き起こすため、非同期的なAPIを呼び出すように注意されています。

ここで、わざとcallback内で同期的なAPIを呼び出してみた場合の挙動を確認してみます。

def main():
    CLIENT_ID = "test1_Core"
    client = create_mqtt_connection(client_id=CLIENT_ID)
    shadow_handler = AWSIoTMQTTShadowClient(
        clientID=CLIENT_ID, awsIoTMQTTClient=client
    ).createShadowHandlerWithName(shadowName=CLIENT_ID, isPersistentSubscribe=True)

    def subscribe_callback(_client, _userdata, message):
        print(f"Received a new message: {message.payload.decode('utf-8')}")
        def subscribe_callback2(_client, _userdata, message):
            print("subscribe_callback2 is called")

        client.subscribe(topic="hello/world2", QoS=1, callback=subscribe_callback2)

    client.subscribe(topic="hello/world", QoS=1, callback=subscribe_callback)

    while True:
        time.sleep(5)


if __name__ =

実行してみると、この場合でもsubscribeTimeoutExceptionがでることがわかりました。

> python fail_shadowGet2.py
Received a new message: {
  "message": "Hello from AWS IoT console"
}
Subscribe timed out
Exception in thread Thread-1:
Traceback (most recent call last):
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/threading.py", line 926, in _bootstrap_inner
    self.run()
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/threading.py", line 870, in run
    self._target(*self._args, **self._kwargs)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 147, in _dispatch
    self._dispatch_one()
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 154, in _dispatch_one
    self._dispatch_methods[event_type](mid, data)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/internal/workers.py", line 237, in _dispatch_message
    message_callback(None, None, message)  # message_callback(client, userdata, message)
  File "fail_shadowGet2.py", line 54, in subscribe_callback
    client.subscribe(topic="hello/world2", QoS=1, callback=subscribe_callback2)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/MQTTLib.py", line 696, in subscribe
    return self._mqtt_core.subscribe(topic, QoS, callback)
  File "/Users/saso/.pyenv/versions/3.7.5/lib/python3.7/site-packages/AWSIoTPythonSDK/core/protocol/mqtt_core.py", line 306, in subscribe
    raise subscribeTimeoutException()
AWSIoTPythonSDK.exception.AWSIoTExceptions.subscribeTimeoutException

callback内で同期的なAPIを呼び出すとsubscribeTimeoutExceptionの例外があがることがわかりました。

調査でわかったことの整理とまとめ

ここまでにわかったことをまとめると以下のようになります。

  • 調査2~4
    • shadowGetは2つのトピックを同期的なAPIのsubscribeを使ってサブスクライブする
  • 調査5
    • callback内で同期的なAPIを使用するとsubscribeTimeoutExceptionが発生する

したがって、今回起きていた問題の原因は、callback内で同期的なAPIのsubscribeを実行するshadowGetを実行してしまうことで、subscribeTimeoutException が発生してしまっていた、ということが原因を特定することができました。めでたし、めでたし。

対応策

いくつか考えられる対応策について説明します。

対応策1. callback前にsubscribeをすませておく

「調査3. shadowGetの実装について」で見たように、shadowGetではサブスクライブするかどうかの条件があります。接続を永続化する設定かつ、すでにサブスクライブ済みであれば、同期的なサブスクライブは行われません。

    def shadowGet(self, srcCallback, srcTimeout):
        # (省略)

        # Two subscriptions
        if not self._isPersistentSubscribe or not self._isGetSubscribed:
            self._shadowManagerHandler.basicShadowSubscribe(self._shadowName, "get", self.generalCallback)
            self._isGetSubscribed = True
            self._logger.info("Subscribed to get accepted/rejected topics for deviceShadow: " + self._shadowName)
        # One publish
        self._shadowManagerHandler.basicShadowPublish(self._shadowName, "get", currentPayload)

        # (省略)

よって、callback外で一度shadowGetを呼ぶことで、それ以降のshadowGetでは同期的なサブスクライブを避けることができます。

def shadowGet_callback(payload: str, response_status: str, token: str):
    print(f"shadow: {json.loads(payload)}")


def main():
    CLIENT_ID = "test1_Core"
    client = create_mqtt_connection(client_id=CLIENT_ID)
    shadow_handler = build_shadow_handler(client_id=CLIENT_ID, client=client)

    def subscribe_callback(client, userdata, message):
        print(f"Received a new message: {message.payload.decode('utf-8')}")
        shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)

    # 追加:subscribeをする前にshadowGetを実行する
    shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)

    client.subscribe(topic="hello/world", QoS=1, callback=subscribe_callback)

    while True:
        time.sleep(5)


if __name__ == "__main__":
    main()

対応策2. subscribeとshadowの取得のMQTTの接続を分ける

同じMQTTの接続の場合にデッドロックが発生してしまうためサブスクライブするMQTTの接続とcallback中に実行するMQTTの接続を分けることで、デッドロックを回避することが可能です。

def shadowGet_callback(payload: str, response_status: str, token: str):
    print(f"shadow: {json.loads(payload)}")


def main():
    client = create_mqtt_connection(client_id="test1_Core")
    shadow_client = create_mqtt_connection(client_id="test1_Core_shadow")
    shadow_handler = AWSIoTMQTTShadowClient(
        clientID="shadow_client", awsIoTMQTTClient=shadow_client
    ).createShadowHandlerWithName(shadowName=CLIENT_ID, isPersistentSubscribe=True)

    def subscribe_callback(client, userdata, message):
        print(f"Received a new message: {message.payload.decode('utf-8')}")
        shadow_handler.shadowGet(srcCallback=shadowGet_callback, srcTimeout=10)

    client.subscribe(topic="hello/world", QoS=1, callback=subscribe_callback)

    while True:
        time.sleep(5)


if __name__ == "__main__":
    main()

対応策3. boto3を使用してshadowの取得をする

こちらは未確認ですが、DeviceShadowはboto3経由でも取得することが可能なので、boto3を使えばMQTTのデッドロックは回避することは可能かと思います。

boto3.amazonaws.com

boto3のトークンにはAWS Security Token Serviceを使えばGreengrassの証明書から作れそうですが、こちらも未確認です。

docs.aws.amazon.com

感想

今回の問題は、READMEに目を通しておいてAWSIoTPythonの仕様を把握しており、shadowGetに同期的なsubscribeが行われているだろうという推理力があればある程度原因の特定まではできたのかもしれないなとは感じました。普段は便利すぎて、あまり内部実装に関心をもてていなかったですが、もう少し自分の使っているSDKやライブラリに関心を持たないといけないなぁと反省しました。

また、例外のクラス名でGoogle検索や、Githubでopenなissueがないかは探していたのですが、closeされたissueにcallback中に同期的なAPIを呼び出すケースで困っている人がいたようです。例外が上がるのが仕様の場合には、issueはcloseされるものかと思うので、closeされたissueも確認した方が良いということ学びました。

github.com

おまけ - デッドロックが起きるフロー

おまけで、callback内で同期的なsubscribeの関数を呼ぶとデッドロックが起きるフローについて挙動をおってみます。

subscribe関数の実装

subscribe関数をまずみていきます。

    def subscribe(self, topic, qos, message_callback=None):
        self._logger.info("Performing sync subscribe...")
        ret = False
        if ClientStatus.STABLE != self._client_status.get_status():
            self._handle_offline_request(RequestTypes.SUBSCRIBE, (topic, qos, message_callback, None))
        else:
            event = Event()
            rc, mid = self._subscribe_async(topic, qos, self._create_blocking_ack_callback(event), message_callback)
            if not event.wait(self._operation_timeout_sec):
                self._internal_async_client.remove_event_callback(mid)
                self._logger.error("Subscribe timed out")
                raise subscribeTimeoutException()
            ret = True
        return ret

github.com

eventという変数について注目してみます。

            event = Event()
            rc, mid = self._subscribe_async(topic, qos, self._create_blocking_ack_callback(event), message_callback)
            if not event.wait(self._operation_timeout_sec):

Eventはpythonの標準のthreading.Eventです。 self._create_blocking_ack_callback(event)event.wait(self._operation_timeout_sec) でeventを参照していることがわかります。

_create_blocking_ack_callbackは以下のような実装になっています。

    def _create_blocking_ack_callback(self, event):
        def ack_callback(mid, data=None):
            event.set()
        return ack_callback

github.com

event.set()を呼ぶだけの関数が渡されていることがわかります。

その次の event.wait(self._operation_timeout_sec) は、event.set()が呼ばれるまで待機するというコードです。

pythonのthreading.Eventについては、以下のブログがわかりやすくまとまっているのでおすすめです。

qiita.com

ここまでをまとめると、subscribe関数は、サブスクライブが完了した時に呼ばれるcallback関数でevent.set()を実行し、subscribe関数内ではevent.wait()でcallback関数が呼ばれるのを待機しています。subscribe関数が正常に完了した時には、サブスクライブされていることが保証されています。

MQTTのやりとりの実装について

さらに細かい話を進めていきます。 AWSIoTPythonでは、MQTTのクライアントにはPaho MQTT Python clientが使われています。

www.eclipse.org

このライブラリではMQTTで接続した時(on_connect)やサブスクライブが完了した時(on_subscribe)やメッセージを受け取った時(on_message)にcallback関数を設定することができます。

callback関数はEventProducerクラスに纏まっています。

class EventProducer(object):

    def __init__(self, cv, event_queue):
        self._cv = cv
        self._event_queue = event_queue

    # 略

    def on_subscribe(self, client, user_data, mid, granted_qos):
        self._add_to_queue(mid, EventTypes.SUBACK, granted_qos)
        self._logger.debug("Produced [suback] event")

    # 略

    def _add_to_queue(self, mid, event_type, data):
        with self._cv:
            self._event_queue.put((mid, event_type, data))
            self._cv.notify()

EventProducerクラスでは、MQTTで何かしらデータを受け取ったら_add_to_queue関数で _event_queue に追加をしているだけで、実際の処理を行うのは他のクラスになります。 _cv という変数には、pythonのthreading.Conditionのオブジェクトが渡されていて、with self._cvself._cv.notify() という使われ方をされています。今回デッドロックを引き起こしているのはまさにこの _cv 変数の部分なのですが、これを参照しているもう一か所のところを触れてから説明します。

省略した部分以外も確認したい場合はソースをみてください。 github.com

実際にpahoのmqttクライアントにEventProducerの関数が登録されているところは、MqttCoreクラスのコンストラクタの_init_worlersあたりの処理を追うとわかるかと思います。

github.com

MQTTを受け取った後の処理について

EventProducerが _event_queue に追加したeventを処理するのは EventConsumerクラスとなっています。

class EventConsumer(object):

    def __init__(self, cv, event_queue, internal_async_client,
                 subscription_manager, offline_requests_manager, client_status):
        self._cv = cv
        # 略

    def start(self):
        #略
        dispatch_events = Thread(target=self._dispatch)
        #略

    def _dispatch(self):
        while self._is_running:
            with self._cv:
                if self._event_queue.empty():
                    self._cv.wait(self.MAX_DISPATCH_INTERNAL_SEC)
                else:
                    while not self._event_queue.empty():
                        self._dispatch_one()
      # 略

EventConsumerで今回の問題に関係するところだけを抜き出しています。 コンストラクタで渡されている cv はEventProducerと同じオブジェクトが渡されていて、共有しています。

_event_queue 経由で渡されるイベントを処理しているのは、 dispatch関数となります。 dispatch関数では with self._cvself._cv.wait_cv を参照しています。

EventConsumerクラスのソースは以下です。

github.com

Pythonのthreading.Conditionについて

threading.Condition は、条件付きのロックでwith文で囲まれたブロック内でロックを獲得することができます。threading.Conditionの面白い(?)ところとしては、wait関数を呼ぶことでConditionオブジェクトのロックを開放して、別のスレッドにConditionオブジェクトでロックをかけて処理をさせることができるようにできます。wait関数はConditionオブジェクトのnotify関数を呼ぶことで再度処理を進めることができます。

docs.python.org

今回のEventConsumer._dispatchではeventキューが空の場合には self._cv.wait(self.MAX_DISPATCH_INTERNAL_SEC) でロックを開放しておきますが、eventを処理している最中にはロックをかけていることがわかります。

    def _dispatch(self):
        while self._is_running:
            with self._cv:
                if self._event_queue.empty():
                    self._cv.wait(self.MAX_DISPATCH_INTERNAL_SEC)
                else:
                    while not self._event_queue.empty():
                        self._dispatch_one()

EventProducerクラスのeventキューに追加しているところをもう一度みてみると、ロックがかかっている間は、MQTTからデータを受け取った後にeventキューに追加することはできません。

    def _add_to_queue(self, mid, event_type, data):
        with self._cv:
            self._event_queue.put((mid, event_type, data))
            self._cv.notify()

したがって、 _dispatch の処理中に新しくMQTTで受け取ったデータは、 _dispatch が完了するまで取得することができません。

同期的なsubscribeでは、MQTTでサブスクライブが完了したデータ(on_subscribe)を受け取って、dispatch内でcallbackの実行(event.set())を呼ぶことを期待しています。ただし、subscribeのcallback内(dispatch関数で _cv にロックをかけた状態)で同期的なsubscribeの関数を呼ぶと、EventProducerがon_subscribeでeventキューに新しく追加しようとしても _cv のロックがかかっているためロックの開放をまってしまいます。subscribeのcallbackも、event.set()が呼ばれるまで _cv を解放できないという状態になってしまうため、デッドロックが生じてしまっているということがわかりました。

おまけの感想

非同期的な処理がはいると、処理を追うのがかなり大変でした。実際、今回例外が上がる箇所から、どこでデッドロックが起きているかの特定は、ブログを書き始めた後の最後の方でやっと特定できた感じでした。 ただ、SDK内でもログレベルをDEBUGにするといろいろログをはいてくれるのでかなり助かりました。

pythonのthreading周りのドキュメントは何度か読んだことはあるはずなのですが、threading.Conditionはすっかり忘れていたので、具体的なユースケースを目にすることができたのはとてもよかったなぁと感じました。

AWS ConsoleのMFAにYubikeyを使えるようになったので雑に調べた

Twitterを眺めていたら以下の記事を見かけました。

Sign in to your AWS Management Console with YubiKey Security Key for Multi-factor Authentication (MFA)

AWSのコンソールのログインにYubikeyをMFAとして使用できるようになったようです。

サクッとどんなものか見てみました。

IAMのユーザ画面

MFAを有効にしたいアカウントの管理画面です。

f:id:otameshi61:20180926100048p:plain

MFA デバイスの管理

MFAデバイスの割り当ての管理を押すと、U2Fセキュリティキーで、Yubikeyが選択できそうなことがわかります。

f:id:otameshi61:20180926100210p:plain

Yubikeyの登録

続行を押すと、以下の画面が表示されます。Yubikeyをタップすると、Chromeに許可するか確認され、許可をすると情報が送信されます。これで登録は完了です。簡単!

f:id:otameshi61:20180926100455p:plain

ログイン

IDとパスワードを入力後に以下の画面に遷移してタップを求められるようになりました。タップすれば入れます。

f:id:otameshi61:20180926101143p:plain

以下、気になったことです。

気になったこと: 登録できるデバイス数は?

ひとつのIAMアカウントに対して登録できるMFAデバイスは1つなので、バックアップ用などに複数Yubikeyを持ってる場合は、それごとにIAMアカウントを作成して登録する必要がありそうです。

気になったこと: Yubikey以外のU2F対応デバイスは使えるのか?

U2Fは公開されたプロトコルで、U2Fを実装しているデバイスはいろいろあります。最近、Googleが出したTitan Security Keysなどもその一つです。

手元にあった飛天の出してるデバイスとU2F ZEROというデバイスで登録を試してみましたが、登録時に、

{"errors":[{"message":"Attestation Certificate is not valid. ","code":"U2fDeviceUnauthorizedException","httpStatus":401,"__type__":"ErrorMessage"}]}

とのことで証明書エラーが返されて使用できませんでした。使えるメーカーを限定してるのはちょっと残念です。

気になったこと: AWS CLIからでも使えるのか?

サポートページをみる感じだと今のところ使えないようです。コマンドラインからU2F使うのはいろいろ工夫がいりそうな印象を持っているので今後もあまり期待できないかもです。

docs.aws.amazon.com

所感

記事のタイトルからして、U2Fに対応したんだろうな、ということは何となく予想できましたが、Yubikeyに限定したタイトルなのはどういうことだろうと思い情報を追ってみました。限定している点はちょっと残念ですが、個人的にはYubikeyを持っているのでMFAが使いやすくなって良かったです。

CyberRebeatCTFに参加した

2018/9/8 ~ 9/9 で開催されていたCyberRebetCTFにソロで参加していて完走できました。

ennach.sakura.ne.jp

結果は全問とけたので1/147位でした。他にも10数チーム全完していて、解けた順番的には結構後ろの方だった気がします。

よくわからないチーム名で登録していましたが、自分でもなんでこのチーム名にしたのかはわかりません。疲れてたんだと思います。

f:id:otameshi61:20180909144804p:plain

f:id:otameshi61:20180909144953p:plain ※ 問題の得点が変動するのでタイミング的に写真の得点がずれてますね

個人的に特に楽しめた問題を二つ紹介します。

Last 5 boxes

The FLAG is hiding at the last 5 boxes.

https://cyberrebeat.adctf.online/static/a4e796eabf01249f6eb8d565ee66849a5bacb472d4ea8adcc6b4dda8f97d318c.mp4

Steganoのジャンルでmp4ファイルが渡される問題。exiftoolとかで情報をみても特に目ぼしい情報はありませんでした。問題文のboxっていうのがmp4の構造を示す言葉かなと思い「mp4 box」でぐぐると以下のブログがヒット。

qiita.com

MP4Box -std -diso  a4e796eabf01249f6eb8d565ee66849a5bacb472d4ea8adcc6b4dda8f97d318c.mp4

で以下がわかるので、抜き出して、繋げたらpngになった。

<UUIDBox Size="1024" Type="uuid" UUID="{18E66A6B-FBDF0D41-80DF83B8-E1CE2B59}" Specification="unknown" Container="unknown" ></UUIDBox>
<UUIDBox Size="1024" Type="uuid" UUID="{07B14494-B8E2AF4D-9BD6652B-52052AC6}" Specification="unknown" Container="unknown" ></UUIDBox>
<UUIDBox Size="1024" Type="uuid" UUID="{14C6D472-C69F4846-ACD23749-2ED79CB9}" Specification="unknown" Container="unknown" ></UUIDBox>
<UUIDBox Size="1024" Type="uuid" UUID="{792E16CC-B4887445-AC310002-334FD627}" Specification="unknown" Container="unknown" ></UUIDBox>
<UUIDBox Size="1612" Type="uuid" UUID="{C386DEC1-144E214D-9BBE788C-4474F39F}" Specification="unknown" Container="unknown" ></UUIDBox>
from binascii import unhexlify

def read_until(f, delim='\n'):
    data = b''
    while not data.endswith(delim):
        data += f.read(1)
    return data

uuids = [
    ('18E66A6BFBDF0D4180DF83B8E1CE2B59', 1024),
    ('07B14494B8E2AF4D9BD6652B52052AC6', 1024),
    ('14C6D472C69F4846ACD237492ED79CB9', 1024),
    ('792E16CCB4887445AC310002334FD627', 1024),
    ('C386DEC1144E214D9BBE788C4474F39F', 1612)
]

for uuid, size in uuids:
    # split -b 4m  a4e796eabf01249f6eb8d565ee66849a5bacb472d4ea8adcc6b4dda8f97d318c.mp4 video.mp4-
    with open('video.mp4-af', 'rb') as fp:
        _ = read_until(fp, unhexlify(uuid))
        s = fp.read(size - 24)
        with open(uuid, 'wb') as f:
            f.write(s)
cat 18E66A6BFBDF0D4180DF83B8E1CE2B59 07B14494B8E2AF4D9BD6652B52052AC6 14C6D472C69F4846ACD237492ED79CB9 792E16CCB4887445AC310002334FD627 C386DEC1144E214D9BBE788C4474F39F  > flag.png

以下の画像ができる。

f:id:otameshi61:20180909151641p:plain

MP4の構造を知れたので勉強になった。

Opening Movie

http://blazor.cyberrebeat.adctf.online

以下のタグで読み込まれるWebAssemblyを解析する問題。

 <script src="_framework/blazor.js" main="MoviePlayer.dll" entrypoint="MoviePlayer.Program::Main" references="Microsoft.AspNetCore.Blazor.Browser.dll,Microsoft.AspNetCore.Blazor.dll,Microsoft.Extensions.DependencyInjection.Abstractions.dll,Microsoft.Extensions.DependencyInjection.dll,mscorlib.dll,netstandard.dll,System.Core.dll,System.dll,System.Net.Http.dll" linker-enabled="true"></script>

MoviePlayer.dllを落としてきて、ilspyでデコンパイルして調べるとフラグの置いてあるURLがわかる問題でした。WebAssemblyは触ったことなかったので良い経験になりました。

普段はMacを使っていますが、以下の記事を参考にしたら、簡単にilspyを用意できたので良かった(なんか昔はもう少し準備が面倒だった気がする)。

qiita.com

感想

いろいろな問題があって楽しめました。公式サイトのイラストがめっちゃ良かったのと、Signatureの問題文の†真・聖天使猫姫†さんがめっちゃよかったと思います。