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をある程度知っている前提で記事を書いているので、あまり馴染みがない場合には以下のブログなどを参考にしてみてください。
扱うリクエスト
- 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
- redirect_uri:
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-configuration
の authorization_endpoint
です。
"authorization_endpoint": "https://oauth2.sigstore.dev/auth/auth",
パラメータから、code grant flowで、openid と emailのスコープを要求していることがわかります。
- response_type: code
- redirect_uri: http://localhost:51217/auth/callback
- scope: 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
- localhostへ
oauth2.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.dev
の token_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-configuration
の token_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の exp
が 1663463568
なので、失効するのは 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-configuration
の jwks_uri
です。
"jwks_uri": "https://oauth2.sigstore.dev/auth/keys",
Id Tokenの検証については以下のブログが参考になります。
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.md
の 2 | Authentication
は、Id Token検証のための鍵の取得
でクライアント側でもId Tokenの検証をしたのと同様のことをfulcioでも行います。
3 | Verifying the challenge
は fulcioとのやりとり
の リクエスト
の検証で確かめたことと同様のことをしています。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のライブラリがまとまっているレポジトリ内にも置かれています。一方で、中間証明書はこのレポジトリには置かれていないようでした。
コードを見るとTUF(The Update Framework) という仕組みを使って証明書を管理し、アップデートの対応をおこなっているようです。
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
)も含まれています。
それぞれ、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.md
の 4 | Constructing a certificate
の画像からもわかるかと思います。
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 に証明書発行のログをアップロードして、その情報を埋め込んだ証明書を作っていることが分かります。
SCTの仕組みについては、Googleのサイト や、以下のような資料が詳しいです。 https://www.jnsa.org/seminar/pki-day/2016/data/1-2_oosumi.pdf
Log IDは、署名に使われた公開鍵のDER形式のsha256の値です。公開鍵はTUFのファイルリストから取得可能です。 計算方法は以下のブログが参考になります。
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を生成しています。
公開鍵は証明書から取り出したものを使用しています。
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に対照イメージのマニフェストのハッシュ値を使っているかは以下のブログの 署名フォーマット
に詳しいです。
リクエスト内のハッシュ値
残りのリクエストの値も見ていきます。
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番良いと思います。
簡単に流れを説明すると、rekorのレスポンスに含まれるbodyから得られる値(下図の②)からleaf(B)を計算し、BとAからCを計算します。その後にCとDからrootのEまで計算します。
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) *Decoder
とfunc (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を取り出している処理を確認できる。
感想
Decoderにはストリームを渡しているのだから、わかってしまえば、それはそうという挙動だと思う。ただ、連続したjsonを扱いたいというわけではなく os.File を便利に扱えるというモチベーションで使用していたので、2個のjsonが含まれるファイルでデコードのエラーにならなかった時には、ちょっとびっくりした。
in-toto attestation の仕様とcosignでの使われ方
はじめに
anchoreのsyftが最近対応したattestationの出力 に使われている in-toto attestation format
の仕様を調査をしたので、備忘録としてまとめておきます。
調べながらの記述となっており、不正確な情報も含まれているかもしれないです。何か気づいたことがあればコメントやtwitterなどで教えていただけると助かります。
TL; DL
in-toto attestation format
はソフトウェアのアーティファクトのメタデータ向けの署名の仕方とデータ形式- メタデータはデータ型を指定する
- アーティファクトの生成方法に関する情報のProvenanceや 脆弱性スキャンの結果 などの公開されているデータ型がある。また、自分で定義したものなどでもよい。
- メタデータ自身もattestationには含まれる
- 対象のアーティファクトはハッシュで指定できる必要がある
例えば、ある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
で、開発段階とのことです。
アーティファクトはハッシュで特定できる必要があり、複数のアーティファクトが同じメタデータを持つこともあります。 仕様の中で例としてあげられているものとしては、
- コンテナイメージ(digestがsha256: 87f7fe...)が作られた「gitコミットのブランチ名、コミットハッシュ」
- git commit "f0c93d…" の「テスト結果」
- コンテナイメージ(digestがsha256: 87f7fe...)の「脆弱性スキャンの結果」
- 配布するバイナリ(zip形式のdigestがsha256:e363cc... で、tar.gz形式のdigestがsha256:d4d589...)の「ビルドされた環境」
などがあります。
構成要素
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
- payloadTypeが
Statement
Statementは対象のソフトウェアのアーティファクトを管理します。
StatementとPredicateは以下のようなjson形式です。Envelopeのpayloadに、このjsonがbase64にエンコードされて埋め込まれています。
{ "_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 は対象のアーティファクトのセット
- アーティファクトによってdigestの種類(Jarファイルの場合はファイルのハッシュか、JAR signingのハッシュか)や、content type(Docker Imageか、ZIP Fileか)の情報を追加した方が良いかなどは議論されていた。しかし、これらを追加するのは複雑性が増すわりにメリットは少なそうということで対応はされなそうな感じになってそう。
- Consider adding a "digest kind" and/or "content type" to subject · Issue #28 · in-toto/attestation · GitHub
- subject[*].digest は アルゴリズムとハッシュ値が入っている
- 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
- TypeURI は RFC3986 のフォーマット
- predicateTypeの例
- 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 OSでGoogle Chromeでの確認の仕方を備忘録として必要なコマンドを残しておきます。
今回は https://my.example.com/
に手元の環境のファイルをホスティングする方法を記載します。
主に参考にした以下のサイトと実行しているコマンドはほぼ同じです。大事なことも多く書かれているので一度読んでみることをお勧めします。
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/
にアクセスすることでファイルにアクセスできます。
以上!
とりあえずファイルにアクセスしたいだけなら、以上です。
その他
その他いくつかためしたことなどのメモです。
ルーティングしたい場合
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! ⚡️
キーチェインで確認すると以下のルート証明書が有効にされていることが確認できます。
以下のコマンドで生成した証明書はブラウザの証明書の確認のところから、ルート証明書とチェーンになっていることがわかります。
$ 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 🗓
Chromeの--host-rules
以下のブログでも紹介されている--host-rulesを使うとhostsファイルのようにドメインとIPのマッピングを更新できるとのこと。実際に試してみたのですが、確かに宛先は変わったのですが証明書のエラーが出てしまって、今回のケースでは使えないようでした。あまりちゃんと原因を追っていないので詳しい人がいたら知りたいです。
こんな感じのコマンで試してました。
/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をデプロイしたり、実行してりできます。
Greengrassでは証明書を使ってAWSリソースの権限を制御することができます。 AWSIoTPythonというSDKを使うことで、Greengrassの証明書を使ってMQTTやMQTT over the WebSocket protocolでAWSのリソースにアクセスすることができます。
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で確認すると以下のようになっています。
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のトピックにメッセージを発行しています。ターミナルの方でプログラムを実行し、メッセージを受け取れていることが確認できます。
実行に失敗するコード
ここからが本記事の本題なのですが、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
APIドキュメントのサイトが公開されており、検索ボックスもあったのでsubscribeTimeoutExceptionで検索をしてみましたが、残念ながら検索ヒット数は0件でした。
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側が用意しているトピックについては、詳細についてはドキュメントに記載されています。
今回の場合は $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
に値がながれてきていることがわかります。
補足ですが、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では、subscribe と subscribeAsync の二通りのサブスクライブするインターフェイスが提供されていることがわかります。名前からある程度察することができますが、AWSIoTPythonではサブスクライブする時に同期的行うか、非同期的に行うかを選ぶことができます。
同期的なsubscribe関数では、関数の実行が完了した時にサブスクライブしていることが保証されますが、非同期的なsubscribeAsync関数ではサブスクライブしていることは保証されていません。
調査2と調査3で確認した通り、shadowGetではまずはトピックをサブスクライブした後に、パブリッシュをして値を取得しています。そのため、サブスクライブが完了していることが保証されるsubscribe関数を使用する必要があります。
getShadowは、トピックのサブスクライブをする時に、特にsbscribe関数を使っているということがわかります。
調査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
関数の一覧の中には 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のトークンにはAWS Security Token Serviceを使えばGreengrassの証明書から作れそうですが、こちらも未確認です。
感想
今回の問題は、READMEに目を通しておいてAWSIoTPythonの仕様を把握しており、shadowGetに同期的なsubscribeが行われているだろうという推理力があればある程度原因の特定まではできたのかもしれないなとは感じました。普段は便利すぎて、あまり内部実装に関心をもてていなかったですが、もう少し自分の使っているSDKやライブラリに関心を持たないといけないなぁと反省しました。
また、例外のクラス名でGoogle検索や、Githubでopenなissueがないかは探していたのですが、closeされたissueにcallback中に同期的なAPIを呼び出すケースで困っている人がいたようです。例外が上がるのが仕様の場合には、issueはcloseされるものかと思うので、closeされたissueも確認した方が良いということ学びました。
おまけ - デッドロックが起きるフロー
おまけで、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
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
event.set()を呼ぶだけの関数が渡されていることがわかります。
その次の event.wait(self._operation_timeout_sec)
は、event.set()が呼ばれるまで待機するというコードです。
pythonのthreading.Eventについては、以下のブログがわかりやすくまとまっているのでおすすめです。
ここまでをまとめると、subscribe関数は、サブスクライブが完了した時に呼ばれるcallback関数でevent.set()を実行し、subscribe関数内ではevent.wait()でcallback関数が呼ばれるのを待機しています。subscribe関数が正常に完了した時には、サブスクライブされていることが保証されています。
MQTTのやりとりの実装について
さらに細かい話を進めていきます。 AWSIoTPythonでは、MQTTのクライアントにはPaho MQTT Python clientが使われています。
このライブラリでは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._cv
と self._cv.notify()
という使われ方をされています。今回デッドロックを引き起こしているのはまさにこの _cv
変数の部分なのですが、これを参照しているもう一か所のところを触れてから説明します。
省略した部分以外も確認したい場合はソースをみてください。 github.com
実際にpahoのmqttクライアントにEventProducerの関数が登録されているところは、MqttCoreクラスのコンストラクタの_init_worlersあたりの処理を追うとわかるかと思います。
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._cv
と self._cv.wait
で _cv
を参照しています。
EventConsumerクラスのソースは以下です。
Pythonのthreading.Conditionについて
threading.Condition は、条件付きのロックでwith文で囲まれたブロック内でロックを獲得することができます。threading.Conditionの面白い(?)ところとしては、wait関数を呼ぶことでConditionオブジェクトのロックを開放して、別のスレッドにConditionオブジェクトでロックをかけて処理をさせることができるようにできます。wait関数はConditionオブジェクトのnotify関数を呼ぶことで再度処理を進めることができます。
今回の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を眺めていたら以下の記事を見かけました。
AWSのコンソールのログインにYubikeyをMFAとして使用できるようになったようです。
サクッとどんなものか見てみました。
IAMのユーザ画面
MFAを有効にしたいアカウントの管理画面です。
MFA デバイスの管理
MFAデバイスの割り当ての管理を押すと、U2Fセキュリティキーで、Yubikeyが選択できそうなことがわかります。
Yubikeyの登録
続行を押すと、以下の画面が表示されます。Yubikeyをタップすると、Chromeに許可するか確認され、許可をすると情報が送信されます。これで登録は完了です。簡単!
ログイン
IDとパスワードを入力後に以下の画面に遷移してタップを求められるようになりました。タップすれば入れます。
以下、気になったことです。
気になったこと: 登録できるデバイス数は?
ひとつの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使うのはいろいろ工夫がいりそうな印象を持っているので今後もあまり期待できないかもです。
所感
記事のタイトルからして、U2Fに対応したんだろうな、ということは何となく予想できましたが、Yubikeyに限定したタイトルなのはどういうことだろうと思い情報を追ってみました。限定している点はちょっと残念ですが、個人的にはYubikeyを持っているのでMFAが使いやすくなって良かったです。
CyberRebeatCTFに参加した
2018/9/8 ~ 9/9 で開催されていたCyberRebetCTFにソロで参加していて完走できました。
結果は全問とけたので1/147位でした。他にも10数チーム全完していて、解けた順番的には結構後ろの方だった気がします。
よくわからないチーム名で登録していましたが、自分でもなんでこのチーム名にしたのかはわかりません。疲れてたんだと思います。
※ 問題の得点が変動するのでタイミング的に写真の得点がずれてますね
個人的に特に楽しめた問題を二つ紹介します。
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」でぐぐると以下のブログがヒット。
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
以下の画像ができる。
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を用意できたので良かった(なんか昔はもう少し準備が面倒だった気がする)。
感想
いろいろな問題があって楽しめました。公式サイトのイラストがめっちゃ良かったのと、Signatureの問題文の†真・聖天使猫姫†さんがめっちゃよかったと思います。