From e66d35409162a3504c9998b1cc637ddf709a9b13 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 18 Oct 2026 08:52:49 +0200 Subject: [PATCH 1/4] feat(upgrade): verify the sigstore signature on checksums.txt bodek upgrade now verifies checksums.txt.bundle (Fulcio chain, release.yml identity pinned to the exact tag, Rekor SET and inclusion proof) with the standard library before trusting any checksum; a release without a bundle is refused. --skip-signature falls back to checksum-only with a warning. Ported from odek's verifier. Co-Authored-By: Claude Opus 5.5 --- cmd/bodek/testdata/upgrade/checksums.txt | 6 + .../testdata/upgrade/checksums.txt.bundle | 1 + cmd/bodek/upgrade.go | 40 +- cmd/bodek/upgrade_sigstore.go | 530 ++++++++++++++++++ cmd/bodek/upgrade_sigstore_test.go | 469 ++++++++++++++++ cmd/bodek/upgrade_test.go | 148 ++++- cmd/bodek/version.go | 33 +- cmd/bodek/version_test.go | 16 + internal/update/update.go | 5 +- internal/update/update_test.go | 2 +- 10 files changed, 1229 insertions(+), 21 deletions(-) create mode 100644 cmd/bodek/testdata/upgrade/checksums.txt create mode 100644 cmd/bodek/testdata/upgrade/checksums.txt.bundle create mode 100644 cmd/bodek/upgrade_sigstore.go create mode 100644 cmd/bodek/upgrade_sigstore_test.go diff --git a/cmd/bodek/testdata/upgrade/checksums.txt b/cmd/bodek/testdata/upgrade/checksums.txt new file mode 100644 index 0000000..52628b5 --- /dev/null +++ b/cmd/bodek/testdata/upgrade/checksums.txt @@ -0,0 +1,6 @@ +a8cc31ea23f6e5e8b1771bfb756fd8364aa372ef9500893f78509cd3bd9a3014 bodek_1.20.3_darwin_amd64.tar.gz +d7d72a901dfea51b8d4b599bf8361203809cf962a3016dd9550dda4094141635 bodek_1.20.3_darwin_arm64.tar.gz +6e56ac4e9bfaf546cf5d13301e145f9e9a32efb3c5816ab6a653675e8ce6c456 bodek_1.20.3_linux_amd64.tar.gz +ab99105f719cca0a3d0e9ea9019ad1c1aa4ad0ecb7cf71596430349320ab4bb7 bodek_1.20.3_linux_arm64.tar.gz +dd1d2f52b0dd7e291c22a53a2da460ce1d7caf67826eb0ce6ae3b94d877824f1 bodek_1.20.3_windows_amd64.zip +ad747b14da26f30236e7f8511d25f859954ceaa7c38541c4945547b27e3aa2d5 bodek_1.20.3_windows_arm64.zip diff --git a/cmd/bodek/testdata/upgrade/checksums.txt.bundle b/cmd/bodek/testdata/upgrade/checksums.txt.bundle new file mode 100644 index 0000000..6922f38 --- /dev/null +++ b/cmd/bodek/testdata/upgrade/checksums.txt.bundle @@ -0,0 +1 @@ +{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"certificate":{"rawBytes":"MIIG6zCCBnCgAwIBAgIULdkKwP5D7+wCNqn+MsqxVQjcghAwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYxMDE3MTUyMjIwWhcNMjYxMDE3MTUzMjIwWjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUU5FWMEQW9hJ3CMldXYfhrqyP3CGhrlLzpOQJj+KJVm1gXuMuYeGnah9FZ18lztemI+VnFCVMIUPpK+I+XJvhaOCBY8wggWLMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSMEGDAWgBTf0+nPViQRlvmo2OkoVaLGLhhkPzBlBgNVHREBAf8EWzBZhldodHRwczovL2dpdGh1Yi5jb20vQmFja2VuZFN0YWNrMjEvYm9kZWsvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy90YWdzL3YxLjIwLjMwOQYKKwYBBAGDvzABAQQraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTASBgorBgEEAYO/MAECBARwdXNoMDYGCisGAQQBg78wAQMEKGRiNWZkZTZkYTliNWNkMTRiM2UwMmViYTYwYTBjMzE1YThmZWEyYzMwFQYKKwYBBAGDvzABBAQHUmVsZWFzZTAiBgorBgEEAYO/MAEFBBRCYWNrZW5kU3RhY2syMS9ib2RlazAfBgorBgEEAYO/MAEGBBFyZWZzL3RhZ3MvdjEuMjAuMzA7BgorBgEEAYO/MAEIBC0MK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wZwYKKwYBBAGDvzABCQRZDFdodHRwczovL2dpdGh1Yi5jb20vQmFja2VuZFN0YWNrMjEvYm9kZWsvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy90YWdzL3YxLjIwLjMwOAYKKwYBBAGDvzABCgQqDChkYjVmZGU2ZGE5YjVjZDE0YjNlMDJlYmE2MGEwYzMxNWE4ZmVhMmMzMB0GCisGAQQBg78wAQsEDwwNZ2l0aHViLWhvc3RlZDA3BgorBgEEAYO/MAEMBCkMJ2h0dHBzOi8vZ2l0aHViLmNvbS9CYWNrZW5kU3RhY2syMS9ib2RlazA4BgorBgEEAYO/MAENBCoMKGRiNWZkZTZkYTliNWNkMTRiM2UwMmViYTYwYTBjMzE1YThmZWEyYzMwIQYKKwYBBAGDvzABDgQTDBFyZWZzL3RhZ3MvdjEuMjAuMzAaBgorBgEEAYO/MAEPBAwMCjEyNzMwNzQzMTIwMQYKKwYBBAGDvzABEAQjDCFodHRwczovL2dpdGh1Yi5jb20vQmFja2VuZFN0YWNrMjEwGAYKKwYBBAGDvzABEQQKDAg4MzI4MjUzNDBnBgorBgEEAYO/MAESBFkMV2h0dHBzOi8vZ2l0aHViLmNvbS9CYWNrZW5kU3RhY2syMS9ib2Rlay8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbEByZWZzL3RhZ3MvdjEuMjAuMzA4BgorBgEEAYO/MAETBCoMKGRiNWZkZTZkYTliNWNkMTRiM2UwMmViYTYwYTBjMzE1YThmZWEyYzMwFAYKKwYBBAGDvzABFAQGDARwdXNoMFsGCisGAQQBg78wARUETQxLaHR0cHM6Ly9naXRodWIuY29tL0JhY2tlbmRTdGFjazIxL2JvZGVrL2FjdGlvbnMvcnVucy8zOTA0NDYwNjU4NC9hdHRlbXB0cy8xMBYGCisGAQQBg78wARYECAwGcHVibGljMD8GCisGAQQBg78wARgEMQwvcmVwbzpCYWNrZW5kU3RhY2syMS9ib2RlazpyZWY6cmVmcy90YWdzL3YxLjIwLjMwgYsGCisGAQQB1nkCBAIEfQR7AHkAdwDdPTBqxscRMmMZHhyZZzcCokpeuN48rf+HinKALynujgAAAaFKdPIDAAAEAwBIMEYCIQDl2t4KcgA+btWtifJfyvuKYvJnr0y8NgzVgIxIvqq3VgIhAMRX0BCG6KOrdzOacCLoBDK6feFNgBGM+mSUf7cb5RrdMAoGCCqGSM49BAMDA2kAMGYCMQD1MmIzMSFEDQs61UQ5Q+SL7t08q6GqEgThHTXYpl0LHS9TVCQXZ9jlINpQl5X0HsMCMQDFzYG/aDMIPOgp0j9rVsU0sLnjQHG7GgGCvmEecidSjv1tpLbi2ef/wvIBEpZG0To="},"tlogEntries":[{"logIndex":"3296821189","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"hashedrekord","version":"0.0.1"},"integratedTime":"1792250544","inclusionPromise":{"signedEntryTimestamp":"MEUCIHB/ymQb8m6bkO0Qpq8IwKtPvBWZFhrbttNBZeACUadXAiEAvToQlzUd9VC3T4xIgxp9DYtzVF7PgTtOfB/cBKAizmg="},"inclusionProof":{"logIndex":"3174916927","rootHash":"bhLfac0+/wbMA9GzKS2trYiDb4lpMJXA0ZmZAi2Onpo=","treeSize":"3174917761","hashes":["zgmzii06yUMfq2BG1Jl8eudqUQYWpE560yc614Nx9v4=","+01QcnggYtEVAf2aQSyfd0CaSvY5KJzoW2yVtlNFHaE=","56gtTh0pu/qJ9U23YB/G3aJgpSF9UV6YKqYZOSR0dsQ=","cpZB8ldW96Gtz9inHOASm/+tBGtZDwRCgR5fqXbK9pI=","Y+I03m2TJTXK2SFCgiWuUOmkt1bDDai+uc5s2wDsLGo=","8o7owqzZvsk8cTOv3UnTnW0ub9ldREF6bIylhQgVv+I=","SQyrl3rQatWkIgqguySPdzWfcU9ebWbzrWCbGD6E6Hw=","PephACmttpODOaBycormR2rofDQIpEAQDWBp/LcQyso=","bFGwxe6xDQzKRA8krUXhR8oqBhM7nhT3axFy/5EiP5g=","XbVKXFlnuA48i1XYCcESlUHB8/+2mLjYbUi1/9+ruD4=","uYS77LZbd2yZZoUHa2TxXnGhq4SpxsL9aLj2g4MEcdw=","C+mbNVnJA9lp6jZ6jfWurcCm5lpWRhggr7WtC4xmu0g=","of7VDk9Rnywks69HL6/srx5VEOaW8cw13hj9hp+2sO0=","xGYZHO/a6DRN82mJW5k4bhKfDsF+LjzILilI50QHCKo=","FXATjdRtireOoENXvnccda3kWZrIMfOFPFr+cZNyVy8=","ahwEIoN0sDCApbC3oYJD9OLJ0wp0a+vc41wzB4/JGUU=","QcgNaWbDGFYkOXAurnhhi3DU8mkXcxbK1BRso2hhgB0=","ikoMXchfJZJN2lVXlFWJe+I+YhbglVO39nk+PNBe2fE=","w7OdPlzy7mnrtLvUv3uPMoFUxXZBeBfz4KnXmNezpOo=","qlTWNDzERnTBQVcHHXXVPjiUP0SsikGxfcxVlgqVFDo=","gg9GNApNke3+zNhl4tsWtmIycCFrOMCB3lys6XuSQNQ=","NlOclldmKPA1tMKIskqHAwuQDYHp31YwpK+OOMrMccc=","qxzHanAzz57SDdmJe0B7bJK72NTIBbwEMGBBKvDOROw=","xH/DCseLHr9eKoYT8qsORZK7zVdEGYWHuVtsVrD95wY="],"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n3174917761\nbhLfac0+/wbMA9GzKS2trYiDb4lpMJXA0ZmZAi2Onpo=\n\n— rekor.sigstore.dev wNI9ajBFAiEAiK2E78IznhRYfo4Mp11ZNphn/bhwDLSjpbVMBpZSrUQCIB6fiLTeOl5U66Bq0kdcH+Lvl8VT/d6gqhoydCd23TyN\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI1NzVjNTUzOTRmMWRiZTcwNzdlNzkwODFhMDFjZTM0NjYwMTExYzZiMjI2YmQ1NzEzZjM0OTIzNWFhYTgzNTgxIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJR0E3NEJFM3YzNUZVRldFcUg5Z243VFg3ZnFJYi9mRTZ3NUk3WS9uU0NNYkFpRUEzNWdQeXZwcjhIOFhCaTdKVXd0OWNpbmdhaHlFeTQrN1RBY3RpNzhGQ1VNPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVYzJla05EUW01RFowRjNTVUpCWjBsVlRHUnJTM2RRTlVRM0szZERUbkZ1SzAxemNYaFdVV3BqWjJoQmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFplRTFFUlROTlZGVjVUV3BKZDFkb1kwNU5hbGw0VFVSRk0wMVVWWHBOYWtsM1YycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZWVlRWR1YwMUZVVmM1YUVvelEwMXNaRmhaWm1oeWNYbFFNME5IYUhKc1RIcHdUMUVLU21vclMwcFdiVEZuV0hWTmRWbGxSMjVoYURsR1dqRTRiSHAwWlcxSksxWnVSa05XVFVsVlVIQkxLMGtyV0VwMmFHRlBRMEpaT0hkbloxZE1UVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXWkNaMDVXU0ZOTlJVZEVRVmRuUWxSbUNqQXJibEJXYVZGU2JIWnRiekpQYTI5V1lVeEhUR2hvYTFCNlFteENaMDVXU0ZKRlFrRm1PRVZYZWtKYWFHeGtiMlJJVW5kamVtOTJUREprY0dSSGFERUtXV2sxYW1JeU1IWlJiVVpxWVRKV2RWcEdUakJaVjA1eVRXcEZkbGx0T1d0YVYzTjJURzFrY0dSSGFERlphVGt6WWpOS2NscHRlSFprTTAxMlkyMVdjd3BhVjBaNldsTTFOV0pYZUVGamJWWnRZM2s1TUZsWFpIcE1NMWw0VEdwSmQweHFUWGRQVVZsTFMzZFpRa0pCUjBSMmVrRkNRVkZSY21GSVVqQmpTRTAyQ2t4NU9UQmlNblJzWW1rMWFGa3pVbkJpTWpWNlRHMWtjR1JIYURGWmJsWjZXbGhLYW1JeU5UQmFWelV3VEcxT2RtSlVRVk5DWjI5eVFtZEZSVUZaVHk4S1RVRkZRMEpCVW5ka1dFNXZUVVJaUjBOcGMwZEJVVkZDWnpjNGQwRlJUVVZMUjFKcFRsZGFhMXBVV210WlZHeHBUbGRPYTAxVVVtbE5NbFYzVFcxV2FRcFpWRmwzV1ZSQ2FrMTZSVEZaVkdodFdsZEZlVmw2VFhkR1VWbExTM2RaUWtKQlIwUjJla0ZDUWtGUlNGVnRWbk5hVjBaNldsUkJhVUpuYjNKQ1owVkZDa0ZaVHk5TlFVVkdRa0pTUTFsWFRuSmFWelZyVlROU2FGa3ljM2xOVXpscFlqSlNiR0Y2UVdaQ1oyOXlRbWRGUlVGWlR5OU5RVVZIUWtKR2VWcFhXbm9LVEROU2FGb3pUWFprYWtWMVRXcEJkVTE2UVRkQ1oyOXlRbWRGUlVGWlR5OU5RVVZKUWtNd1RVc3lhREJrU0VKNlQyazRkbVJIT1hKYVZ6UjFXVmRPTUFwaFZ6bDFZM2sxYm1GWVVtOWtWMG94WXpKV2VWa3lPWFZrUjFaMVpFTTFhbUl5TUhkYWQxbExTM2RaUWtKQlIwUjJla0ZDUTFGU1drUkdaRzlrU0ZKM0NtTjZiM1pNTW1Sd1pFZG9NVmxwTldwaU1qQjJVVzFHYW1FeVZuVmFSazR3V1ZkT2NrMXFSWFpaYlRscldsZHpka3h0WkhCa1IyZ3hXV2s1TTJJelNuSUtXbTE0ZG1RelRYWmpiVlp6V2xkR2VscFROVFZpVjNoQlkyMVdiV041T1RCWlYyUjZURE5aZUV4cVNYZE1hazEzVDBGWlMwdDNXVUpDUVVkRWRucEJRZ3BEWjFGeFJFTm9hMWxxVm0xYVIxVXlXa2RGTlZscVZtcGFSRVV3V1dwT2JFMUVTbXhaYlVVeVRVZEZkMWw2VFhoT1YwVTBXbTFXYUUxdFRYcE5RakJIQ2tOcGMwZEJVVkZDWnpjNGQwRlJjMFZFZDNkT1dqSnNNR0ZJVm1sTVYyaDJZek5TYkZwRVFUTkNaMjl5UW1kRlJVRlpUeTlOUVVWTlFrTnJUVW95YURBS1pFaENlazlwT0haYU1td3dZVWhXYVV4dFRuWmlVemxEV1ZkT2NscFhOV3RWTTFKb1dUSnplVTFUT1dsaU1sSnNZWHBCTkVKbmIzSkNaMFZGUVZsUEx3cE5RVVZPUWtOdlRVdEhVbWxPVjFwcldsUmFhMWxVYkdsT1YwNXJUVlJTYVUweVZYZE5iVlpwV1ZSWmQxbFVRbXBOZWtVeFdWUm9iVnBYUlhsWmVrMTNDa2xSV1V0TGQxbENRa0ZIUkhaNlFVSkVaMUZVUkVKR2VWcFhXbnBNTTFKb1dqTk5kbVJxUlhWTmFrRjFUWHBCWVVKbmIzSkNaMFZGUVZsUEwwMUJSVkFLUWtGM1RVTnFSWGxPZWsxM1RucFJlazFVU1hkTlVWbExTM2RaUWtKQlIwUjJla0ZDUlVGUmFrUkRSbTlrU0ZKM1kzcHZka3d5WkhCa1IyZ3hXV2sxYWdwaU1qQjJVVzFHYW1FeVZuVmFSazR3V1ZkT2NrMXFSWGRIUVZsTFMzZFpRa0pCUjBSMmVrRkNSVkZSUzBSQlp6Uk5la2swVFdwVmVrNUVRbTVDWjI5eUNrSm5SVVZCV1U4dlRVRkZVMEpHYTAxV01tZ3daRWhDZWs5cE9IWmFNbXd3WVVoV2FVeHRUblppVXpsRFdWZE9jbHBYTld0Vk0xSm9XVEp6ZVUxVE9Xa0tZakpTYkdGNU9IVmFNbXd3WVVoV2FVd3paSFpqYlhSdFlrYzVNMk41T1hsYVYzaHNXVmhPYkV4dWJIUmlSVUo1V2xkYWVrd3pVbWhhTTAxMlpHcEZkUXBOYWtGMVRYcEJORUpuYjNKQ1owVkZRVmxQTDAxQlJWUkNRMjlOUzBkU2FVNVhXbXRhVkZwcldWUnNhVTVYVG10TlZGSnBUVEpWZDAxdFZtbFpWRmwzQ2xsVVFtcE5la1V4V1ZSb2JWcFhSWGxaZWsxM1JrRlpTMHQzV1VKQ1FVZEVkbnBCUWtaQlVVZEVRVkozWkZoT2IwMUdjMGREYVhOSFFWRlJRbWMzT0hjS1FWSlZSVlJSZUV4aFNGSXdZMGhOTmt4NU9XNWhXRkp2WkZkSmRWa3lPWFJNTUVwb1dUSjBiR0p0VWxSa1IwWnFZWHBKZUV3eVNuWmFSMVp5VERKR2FncGtSMngyWW01TmRtTnVWblZqZVRoNlQxUkJNRTVFV1hkT2FsVTBUa001YUdSSVVteGlXRUl3WTNrNGVFMUNXVWREYVhOSFFWRlJRbWMzT0hkQlVsbEZDa05CZDBkalNGWnBZa2RzYWsxRU9FZERhWE5IUVZGUlFtYzNPSGRCVW1kRlRWRjNkbU50Vm5kaWVuQkRXVmRPY2xwWE5XdFZNMUpvV1RKemVVMVRPV2tLWWpKU2JHRjZjSGxhVjFrMlkyMVdiV041T1RCWlYyUjZURE5aZUV4cVNYZE1hazEzWjFselIwTnBjMGRCVVZGQ01XNXJRMEpCU1VWbVVWSTNRVWhyUVFwa2QwUmtVRlJDY1hoelkxSk5iVTFhU0doNVdscDZZME52YTNCbGRVNDBPSEptSzBocGJrdEJUSGx1ZFdwblFVRkJZVVpMWkZCSlJFRkJRVVZCZDBKSkNrMUZXVU5KVVVSc01uUTBTMk5uUVN0aWRGZDBhV1pLWm5sMmRVdFpka3B1Y2pCNU9FNW5lbFpuU1hoSmRuRnhNMVpuU1doQlRWSllNRUpEUnpaTFQzSUtaSHBQWVdORFRHOUNSRXMyWm1WR1RtZENSMDByYlZOVlpqZGpZalZTY21STlFXOUhRME54UjFOTk5EbENRVTFFUVRKclFVMUhXVU5OVVVReFRXMUplZ3BOVTBaRlJGRnpOakZWVVRWUksxTk1OM1F3T0hFMlIzRkZaMVJvU0ZSWVdYQnNNRXhJVXpsVVZrTlJXRm81YW14SlRuQlJiRFZZTUVoelRVTk5VVVJHQ25wWlJ5OWhSRTFKVUU5bmNEQnFPWEpXYzFVd2MweHVhbEZJUnpkSFowZERkbTFGWldOcFpGTnFkakYwY0V4aWFUSmxaaTkzZGtsQ1JYQmFSekJVYnowS0xTMHRMUzFGVGtRZ1EwVlNWRWxHU1VOQlZFVXRMUzB0TFFvPSJ9fX19"}],"timestampVerificationData":{"rfc3161Timestamps":[{"signedTimestamp":"MIICyTADAgEAMIICwAYJKoZIhvcNAQcCoIICsTCCAq0CAQMxDTALBglghkgBZQMEAgEwgbgGCyqGSIb3DQEJEAEEoIGoBIGlMIGiAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgWNaB12s7abjFy+c0//R1R/01z6soi0IdzwwUVpmLCSwCFQCh8Q56sRPO4Z6wcEq3Ap3lJL5F2BgPMjAyNjEwMTcxNTIyMjBaMAMCAQGgMqQwMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhoAAxggHaMIIB1gIBATBRMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQCFDoTVC8MkGHuvMFDL8uKjosqI4sMMAsGCWCGSAFlAwQCAaCB/DAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkFMQ8XDTI2MTAxNzE1MjIyMFowLwYJKoZIhvcNAQkEMSIEIFyLLTcql4vA/BV/VyeIFLBhLoRVI1MkCRVpyBl/4PN1MIGOBgsqhkiG9w0BCRACLzF/MH0wezB5BCCF+Se8B6tiysO0Q1bBDvyBssaIP9p6uebYcNnROs0FtzBVMD2kOzA5MRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxIDAeBgNVBAMTF3NpZ3N0b3JlLXRzYS1zZWxmc2lnbmVkAhQ6E1QvDJBh7rzBQy/Lio6LKiOLDDAKBggqhkjOPQQDAgRmMGQCMGL6UOUfPZ0pzFUmB1h/8Xf2JJu7XzS3ynI2bo1QsOJYLzdU7Eh05Yi0ALNmGr+1owIwC3EgNhN+XCLCmz3rF+AqndTKrP4VZIWf57clw8qmcuX3nZMupy9jAvZ3htJMsXz3"}]}},"messageSignature":{"messageDigest":{"algorithm":"SHA2_256","digest":"V1xVOU8dvnB355CBoBzjRmARHGsia9VxPzSSNaqoNYE="},"signature":"MEUCIGA74BE3v35FUFWEqH9gn7TX7fqIb/fE6w5I7Y/nSCMbAiEA35gPyvpr8H8XBi7JUwt9cingahyEy4+7TActi78FCUM="}} \ No newline at end of file diff --git a/cmd/bodek/upgrade.go b/cmd/bodek/upgrade.go index 3af7b9b..76f7e1c 100644 --- a/cmd/bodek/upgrade.go +++ b/cmd/bodek/upgrade.go @@ -21,20 +21,31 @@ import ( "github.com/BackendStack21/bodek/internal/update" ) +// checksumsBundleAsset is the Sigstore bundle release.yml publishes for +// checksums.txt. +const checksumsBundleAsset = "checksums.txt.bundle" + +// upgradeOptions are the `bodek upgrade` flags. +type upgradeOptions struct { + skipSignature bool +} + // runUpgrade self-updates the running executable to the latest release. -func runUpgrade(stdout io.Writer) error { +func runUpgrade(opts upgradeOptions, stdout io.Writer) error { exe, err := os.Executable() if err != nil { return fmt.Errorf("locate current executable: %w", err) } client := &http.Client{Timeout: 10 * time.Second} - return upgrade(context.Background(), client, update.LatestURL, exe, stdout) + return upgrade(context.Background(), client, update.LatestURL, exe, opts, stdout) } // upgrade performs the full self-update against the given API endpoint, -// replacing the executable at exePath. runUpgrade wires in the production -// defaults; tests point it at an httptest server and a temp binary. -func upgrade(ctx context.Context, client *http.Client, apiURL, exePath string, stdout io.Writer) error { +// replacing the executable at exePath. The release's checksums.txt is +// verified against its Sigstore bundle (upgrade_sigstore.go) before any +// checksum in it is trusted. runUpgrade wires in the production defaults; +// tests point it at an httptest server and a temp binary. +func upgrade(ctx context.Context, client *http.Client, apiURL, exePath string, opts upgradeOptions, stdout io.Writer) error { tag, assets, err := update.LatestRelease(ctx, client, apiURL) if err != nil { return err @@ -61,6 +72,10 @@ func upgrade(ctx context.Context, client *http.Client, apiURL, exePath string, s if !ok { return fmt.Errorf("release %s has no checksums.txt asset", tag) } + bundleURL, ok := assets[checksumsBundleAsset] + if !ok && !opts.skipSignature { + return fmt.Errorf("release %s has no %s — refusing to install without a verifiable signature (--skip-signature overrides, unsafe)", tag, checksumsBundleAsset) + } _, _ = fmt.Fprintf(stdout, "downloading %s …\n", name) archive, err := download(ctx, client, archiveURL) @@ -71,6 +86,21 @@ func upgrade(ctx context.Context, client *http.Client, apiURL, exePath string, s if err != nil { return err } + // A matching checksum proves nothing when checksums.txt comes from the + // same, possibly tampered, release: the signature ties it to the release + // workflow at exactly this tag. + if opts.skipSignature { + _, _ = fmt.Fprintln(stdout, "signature verification skipped (--skip-signature)") + } else { + bundle, err := download(ctx, client, bundleURL) + if err != nil { + return err + } + if err := verifyChecksumsBundle(upgradeTrust, bundle, checksums, tag); err != nil { + return fmt.Errorf("release %s signature verification failed: %w — refusing to install", tag, err) + } + _, _ = fmt.Fprintf(stdout, "verified Sigstore signature: %s@refs/tags/%s\n", upgradeWorkflow, tag) + } // Supply-chain check: refuse to install anything the release checksums // don't vouch for. if err := verifyChecksum(archive, checksums, name); err != nil { diff --git a/cmd/bodek/upgrade_sigstore.go b/cmd/bodek/upgrade_sigstore.go new file mode 100644 index 0000000..102d84e --- /dev/null +++ b/cmd/bodek/upgrade_sigstore.go @@ -0,0 +1,530 @@ +package main + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// Sigstore verification of the release checksums.txt. +// +// release.yml signs checksums.txt with keyless cosign: a short-lived Fulcio +// certificate bound to the workflow's GitHub OIDC identity, logged in the +// Rekor transparency log, shipped as a Sigstore bundle (checksums.txt.bundle). +// The verifier below checks that bundle with the standard library only: +// +// 1. the leaf certificate chains to the embedded Fulcio root, evaluated at +// the Rekor-attested integration time (Fulcio leaves live ten minutes); +// 2. the certificate identity is this repository's release workflow at the +// exact tag being installed, issued through GitHub Actions OIDC; +// 3. the message signature verifies over checksums.txt and its digest +// matches the downloaded file; +// 4. the Rekor entry (hashedrekord) records that same digest, signature and +// certificate, its signed entry timestamp verifies against the embedded +// Rekor key, and its inclusion proof, when present, hashes to the root of +// a checkpoint signed by that key. +// +// Every failure refuses the upgrade. The trust material is compiled in, so a +// Sigstore root rotation leaves an older binary unable to verify newer +// releases; `bodek upgrade --skip-signature` falls back to checksum-only. + +const ( + upgradeOIDCIssuer = "https://token.actions.githubusercontent.com" + upgradeSourceRepo = "https://github.com/BackendStack21/bodek" + upgradeWorkflow = upgradeSourceRepo + "/.github/workflows/release.yml" +) + +// Sigstore public-good trust material from the root-signing trusted_root.json +// (Fulcio CA valid from 2022-04-13; Rekor log rekor.sigstore.dev). +const ( + sigstoreFulcioIntermediatePEM = `-----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----- +` + sigstoreFulcioRootPEM = `-----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----- +` + sigstoreRekorKeyPEM = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwr +kBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw== +-----END PUBLIC KEY----- +` +) + +// Fulcio certificate extensions (OID arc 1.3.6.1.4.1.57264.1). +var ( + oidFulcioIssuerLegacy = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1} + oidFulcioIssuer = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8} + oidFulcioSourceRepo = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 12} +) + +// sigstoreTrust is the trust material the verifier checks against. +type sigstoreTrust struct { + roots *x509.CertPool + intermediates *x509.CertPool + // rekorKeys maps a Rekor log ID (hex SHA-256 of the DER public key) to + // that log's public key. + rekorKeys map[string]crypto.PublicKey +} + +// upgradeTrust is the trust material used by upgrade. Tests swap it +// for an in-test CA and log key. +var upgradeTrust = mustDefaultSigstoreTrust() + +func mustDefaultSigstoreTrust() *sigstoreTrust { + t, err := newSigstoreTrust([]string{sigstoreFulcioRootPEM}, []string{sigstoreFulcioIntermediatePEM}, []string{sigstoreRekorKeyPEM}) + if err != nil { + panic("embedded Sigstore trust material is invalid: " + err.Error()) + } + return t +} + +func newSigstoreTrust(rootPEMs, intermediatePEMs, rekorKeyPEMs []string) (*sigstoreTrust, error) { + t := &sigstoreTrust{roots: x509.NewCertPool(), intermediates: x509.NewCertPool(), rekorKeys: map[string]crypto.PublicKey{}} + for _, p := range rootPEMs { + if !t.roots.AppendCertsFromPEM([]byte(p)) { + return nil, errors.New("bad root certificate") + } + } + for _, p := range intermediatePEMs { + if !t.intermediates.AppendCertsFromPEM([]byte(p)) { + return nil, errors.New("bad intermediate certificate") + } + } + for _, p := range rekorKeyPEMs { + blk, _ := pem.Decode([]byte(p)) + if blk == nil { + return nil, errors.New("bad Rekor key PEM") + } + pub, err := x509.ParsePKIXPublicKey(blk.Bytes) + if err != nil { + return nil, fmt.Errorf("bad Rekor key: %w", err) + } + id := sha256.Sum256(blk.Bytes) + t.rekorKeys[hex.EncodeToString(id[:])] = pub + } + return t, nil +} + +// sigstoreBundle is the subset of a Sigstore bundle (v0.3) the verifier reads. +type sigstoreBundle struct { + MediaType string `json:"mediaType"` + VerificationMaterial struct { + Certificate *struct { + RawBytes []byte `json:"rawBytes"` + } `json:"certificate"` + X509CertificateChain *struct { + Certificates []struct { + RawBytes []byte `json:"rawBytes"` + } `json:"certificates"` + } `json:"x509CertificateChain"` + TlogEntries []sigstoreTlogEntry `json:"tlogEntries"` + } `json:"verificationMaterial"` + MessageSignature *struct { + MessageDigest struct { + Algorithm string `json:"algorithm"` + Digest []byte `json:"digest"` + } `json:"messageDigest"` + Signature []byte `json:"signature"` + } `json:"messageSignature"` +} + +type sigstoreTlogEntry struct { + LogIndex string `json:"logIndex"` + LogID struct { + KeyID []byte `json:"keyId"` + } `json:"logId"` + KindVersion struct { + Kind string `json:"kind"` + Version string `json:"version"` + } `json:"kindVersion"` + IntegratedTime string `json:"integratedTime"` + InclusionPromise *struct { + SignedEntryTimestamp []byte `json:"signedEntryTimestamp"` + } `json:"inclusionPromise"` + InclusionProof *struct { + LogIndex string `json:"logIndex"` + RootHash []byte `json:"rootHash"` + TreeSize string `json:"treeSize"` + Hashes [][]byte `json:"hashes"` + Checkpoint struct { + Envelope string `json:"envelope"` + } `json:"checkpoint"` + } `json:"inclusionProof"` + CanonicalizedBody []byte `json:"canonicalizedBody"` +} + +// hashedRekordBody is the canonicalized Rekor entry body of kind +// hashedrekord, version 0.0.1. +type hashedRekordBody struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Spec struct { + Data struct { + Hash struct { + Algorithm string `json:"algorithm"` + Value string `json:"value"` + } `json:"hash"` + } `json:"data"` + Signature struct { + Content []byte `json:"content"` + PublicKey struct { + Content []byte `json:"content"` + } `json:"publicKey"` + } `json:"signature"` + } `json:"spec"` +} + +// verifyChecksumsBundle verifies that bundleJSON is a valid Sigstore +// signature over artifact, made by this repository's release workflow for +// tag. It returns nil only when every check passes. +func verifyChecksumsBundle(trust *sigstoreTrust, bundleJSON, artifact []byte, tag string) error { + if trust == nil { + return errors.New("no Sigstore trust material") + } + var b sigstoreBundle + if err := json.Unmarshal(bundleJSON, &b); err != nil { + return fmt.Errorf("cannot parse bundle: %w", err) + } + if !strings.HasPrefix(b.MediaType, "application/vnd.dev.sigstore.bundle") { + return fmt.Errorf("unsupported bundle media type %q", b.MediaType) + } + if b.MessageSignature == nil { + return errors.New("bundle has no message signature") + } + + leafDER, err := bundleLeafDER(&b) + if err != nil { + return err + } + leaf, err := x509.ParseCertificate(leafDER) + if err != nil { + return fmt.Errorf("cannot parse signing certificate: %w", err) + } + + // The message digest must be the artifact's, and the signature must + // verify over it with the certificate's key. + digest := sha256.Sum256(artifact) + if b.MessageSignature.MessageDigest.Algorithm != "SHA2_256" || + !bytes.Equal(b.MessageSignature.MessageDigest.Digest, digest[:]) { + return errors.New("bundle digest does not match checksums.txt") + } + if err := verifyDigestSignature(leaf.PublicKey, digest[:], b.MessageSignature.Signature); err != nil { + return fmt.Errorf("signature over checksums.txt does not verify: %w", err) + } + + if len(b.VerificationMaterial.TlogEntries) != 1 { + return fmt.Errorf("bundle must carry exactly one transparency-log entry, has %d", len(b.VerificationMaterial.TlogEntries)) + } + integrated, err := verifyTlogEntry(trust, &b.VerificationMaterial.TlogEntries[0], digest[:], b.MessageSignature.Signature, leafDER) + if err != nil { + return err + } + + // Chain to the Fulcio root at the time the log attested the entry. + if _, err := leaf.Verify(x509.VerifyOptions{ + Roots: trust.roots, + Intermediates: trust.intermediates, + CurrentTime: integrated, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + }); err != nil { + return fmt.Errorf("signing certificate does not chain to the Sigstore root: %w", err) + } + return verifyReleaseIdentity(leaf, tag) +} + +func bundleLeafDER(b *sigstoreBundle) ([]byte, error) { + vm := b.VerificationMaterial + switch { + case vm.Certificate != nil && len(vm.Certificate.RawBytes) > 0: + return vm.Certificate.RawBytes, nil + case vm.X509CertificateChain != nil && len(vm.X509CertificateChain.Certificates) > 0: + return vm.X509CertificateChain.Certificates[0].RawBytes, nil + } + return nil, errors.New("bundle has no signing certificate") +} + +// verifyReleaseIdentity pins the certificate to the release workflow of this +// repository at exactly tag, issued through GitHub Actions OIDC. +func verifyReleaseIdentity(leaf *x509.Certificate, tag string) error { + want := upgradeWorkflow + "@refs/tags/" + tag + if len(leaf.URIs) != 1 || leaf.URIs[0].String() != want { + got := make([]string, 0, len(leaf.URIs)) + for _, u := range leaf.URIs { + got = append(got, u.String()) + } + return fmt.Errorf("signing identity %q is not %q", strings.Join(got, ","), want) + } + if len(leaf.EmailAddresses) != 0 || len(leaf.DNSNames) != 0 || len(leaf.IPAddresses) != 0 { + return errors.New("signing certificate carries unexpected identities") + } + issuer, err := fulcioExtension(leaf, oidFulcioIssuer, true) + if err != nil || issuer == "" { + issuer, err = fulcioExtension(leaf, oidFulcioIssuerLegacy, false) + } + if err != nil || issuer != upgradeOIDCIssuer { + return fmt.Errorf("signing certificate OIDC issuer %q is not %q", issuer, upgradeOIDCIssuer) + } + // Older Fulcio certificates lack the source-repository extension; when + // present it must name this repository. + if repo, err := fulcioExtension(leaf, oidFulcioSourceRepo, true); err != nil { + return fmt.Errorf("signing certificate source repository: %w", err) + } else if repo != "" && repo != upgradeSourceRepo { + return fmt.Errorf("signing certificate source repository %q is not %q", repo, upgradeSourceRepo) + } + return nil +} + +// fulcioExtension returns the string value of the extension oid, or "" when +// absent. derString selects the DER UTF8String encoding used by the newer +// Fulcio extensions; the legacy ones carry raw bytes. +func fulcioExtension(cert *x509.Certificate, oid asn1.ObjectIdentifier, derString bool) (string, error) { + for _, ext := range cert.Extensions { + if !ext.Id.Equal(oid) { + continue + } + if !derString { + return string(ext.Value), nil + } + var s string + rest, err := asn1.UnmarshalWithParams(ext.Value, &s, "utf8") + if err != nil { + return "", err + } + if len(rest) != 0 { + return "", errors.New("trailing data in extension") + } + return s, nil + } + return "", nil +} + +// verifyDigestSignature verifies an ASN.1 ECDSA signature over a SHA-256 +// digest (the hashedrekord signing scheme cosign uses for blobs). +func verifyDigestSignature(pub crypto.PublicKey, digest, sig []byte) error { + k, ok := pub.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("unsupported key type %T", pub) + } + if !ecdsa.VerifyASN1(k, digest, sig) { + return errors.New("invalid ECDSA signature") + } + return nil +} + +// verifyTlogEntry checks the Rekor entry and returns its integration time. +func verifyTlogEntry(trust *sigstoreTrust, e *sigstoreTlogEntry, digest, sig, leafDER []byte) (time.Time, error) { + if e.KindVersion.Kind != "hashedrekord" || e.KindVersion.Version != "0.0.1" { + return time.Time{}, fmt.Errorf("unsupported transparency-log entry %s/%s", e.KindVersion.Kind, e.KindVersion.Version) + } + logID := hex.EncodeToString(e.LogID.KeyID) + logKey, ok := trust.rekorKeys[logID] + if !ok { + return time.Time{}, fmt.Errorf("transparency log %s is not trusted", logID) + } + + // The entry must record exactly this digest, signature and certificate. + var body hashedRekordBody + if err := json.Unmarshal(e.CanonicalizedBody, &body); err != nil { + return time.Time{}, fmt.Errorf("cannot parse transparency-log entry: %w", err) + } + if body.Kind != "hashedrekord" || body.APIVersion != "0.0.1" { + return time.Time{}, errors.New("transparency-log entry body is not hashedrekord 0.0.1") + } + if body.Spec.Data.Hash.Algorithm != "sha256" || !strings.EqualFold(body.Spec.Data.Hash.Value, hex.EncodeToString(digest)) { + return time.Time{}, errors.New("transparency-log entry records a different digest") + } + if !bytes.Equal(body.Spec.Signature.Content, sig) { + return time.Time{}, errors.New("transparency-log entry records a different signature") + } + blk, _ := pem.Decode(body.Spec.Signature.PublicKey.Content) + if blk == nil || blk.Type != "CERTIFICATE" || !bytes.Equal(blk.Bytes, leafDER) { + return time.Time{}, errors.New("transparency-log entry records a different certificate") + } + + logIndex, err := strconv.ParseInt(e.LogIndex, 10, 64) + if err != nil || logIndex < 0 { + return time.Time{}, fmt.Errorf("bad log index %q", e.LogIndex) + } + integratedTime, err := strconv.ParseInt(e.IntegratedTime, 10, 64) + if err != nil || integratedTime <= 0 { + return time.Time{}, fmt.Errorf("bad integrated time %q", e.IntegratedTime) + } + + // The signed entry timestamp is the log's promise over body, time and + // index; it is what makes the integration time trustworthy. + if e.InclusionPromise == nil || len(e.InclusionPromise.SignedEntryTimestamp) == 0 { + return time.Time{}, errors.New("transparency-log entry has no signed entry timestamp") + } + setPayload, err := json.Marshal(struct { + Body string `json:"body"` + IntegratedTime int64 `json:"integratedTime"` + LogID string `json:"logID"` + LogIndex int64 `json:"logIndex"` + }{base64.StdEncoding.EncodeToString(e.CanonicalizedBody), integratedTime, logID, logIndex}) + if err != nil { + return time.Time{}, err + } + if err := verifyLogSignature(logKey, setPayload, e.InclusionPromise.SignedEntryTimestamp); err != nil { + return time.Time{}, fmt.Errorf("signed entry timestamp does not verify: %w", err) + } + + if e.InclusionProof != nil { + if err := verifyInclusionProof(logKey, e, e.CanonicalizedBody); err != nil { + return time.Time{}, fmt.Errorf("inclusion proof: %w", err) + } + } + return time.Unix(integratedTime, 0), nil +} + +// verifyLogSignature verifies a log signature over msg (SHA-256 for ECDSA, +// the raw message for Ed25519). +func verifyLogSignature(pub crypto.PublicKey, msg, sig []byte) error { + switch k := pub.(type) { + case *ecdsa.PublicKey: + h := sha256.Sum256(msg) + if !ecdsa.VerifyASN1(k, h[:], sig) { + return errors.New("invalid ECDSA signature") + } + case ed25519.PublicKey: + if !ed25519.Verify(k, msg, sig) { + return errors.New("invalid Ed25519 signature") + } + default: + return fmt.Errorf("unsupported log key type %T", pub) + } + return nil +} + +// verifyInclusionProof checks the RFC 6962 audit path from the entry's leaf +// to the proof's root hash and that the root is the one in a checkpoint +// signed by the log. +func verifyInclusionProof(logKey crypto.PublicKey, e *sigstoreTlogEntry, body []byte) error { + p := e.InclusionProof + index, err := strconv.ParseUint(p.LogIndex, 10, 64) + if err != nil { + return fmt.Errorf("bad index %q", p.LogIndex) + } + size, err := strconv.ParseUint(p.TreeSize, 10, 64) + if err != nil || index >= size { + return fmt.Errorf("bad tree size %q for index %d", p.TreeSize, index) + } + leaf := sha256.Sum256(append([]byte{0}, body...)) + root, err := rfc6962RootFromPath(index, size, leaf[:], p.Hashes) + if err != nil { + return err + } + if !bytes.Equal(root, p.RootHash) { + return errors.New("audit path does not hash to the root") + } + return verifyCheckpoint(logKey, e.LogID.KeyID, p.Checkpoint.Envelope, size, p.RootHash) +} + +// rfc6962RootFromPath computes the Merkle tree root from a leaf hash and its +// audit path (RFC 9162 section 2.1.3.2). +func rfc6962RootFromPath(index, size uint64, leaf []byte, path [][]byte) ([]byte, error) { + fn, sn := index, size-1 + r := leaf + for _, p := range path { + if len(p) != sha256.Size { + return nil, errors.New("bad audit path hash") + } + if sn == 0 { + return nil, errors.New("audit path too long") + } + if fn&1 == 1 || fn == sn { + r = hashChildren(p, r) + for fn&1 == 0 && fn != 0 { + fn >>= 1 + sn >>= 1 + } + } else { + r = hashChildren(r, p) + } + fn >>= 1 + sn >>= 1 + } + if sn != 0 { + return nil, errors.New("audit path too short") + } + return r, nil +} + +func hashChildren(l, r []byte) []byte { + h := sha256.New() + h.Write([]byte{1}) + h.Write(l) + h.Write(r) + return h.Sum(nil) +} + +// verifyCheckpoint verifies a signed-note checkpoint ("origin\nsize\nroot\n", +// a blank line, then "— name base64(keyhint||sig)" lines): one signature +// whose key hint matches the log ID must verify, and the checkpoint must +// commit to size and root. +func verifyCheckpoint(logKey crypto.PublicKey, logID []byte, envelope string, size uint64, root []byte) error { + text, sigs, ok := strings.Cut(envelope, "\n\n") + if !ok { + return errors.New("malformed checkpoint") + } + text += "\n" + lines := strings.Split(strings.TrimSuffix(text, "\n"), "\n") + if len(lines) < 3 { + return errors.New("malformed checkpoint body") + } + if lines[1] != strconv.FormatUint(size, 10) || lines[2] != base64.StdEncoding.EncodeToString(root) { + return errors.New("checkpoint does not commit to the proof's tree") + } + for _, line := range strings.Split(sigs, "\n") { + if !strings.HasPrefix(line, "— ") { + continue + } + fields := strings.Fields(strings.TrimPrefix(line, "— ")) + if len(fields) != 2 { + continue + } + raw, err := base64.StdEncoding.DecodeString(fields[1]) + if err != nil || len(raw) < 5 || len(logID) < 4 || !bytes.Equal(raw[:4], logID[:4]) { + continue + } + if verifyLogSignature(logKey, []byte(text), raw[4:]) == nil { + return nil + } + } + return errors.New("no valid log signature on checkpoint") +} diff --git a/cmd/bodek/upgrade_sigstore_test.go b/cmd/bodek/upgrade_sigstore_test.go new file mode 100644 index 0000000..1abcd3a --- /dev/null +++ b/cmd/bodek/upgrade_sigstore_test.go @@ -0,0 +1,469 @@ +package main + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// testSigstore is an in-test Fulcio-like CA plus a Rekor-like log key. +type testSigstore struct { + rootKey, interKey *ecdsa.PrivateKey + root, inter *x509.Certificate + rekorKey *ecdsa.PrivateKey + logID []byte + trust *sigstoreTrust +} + +func newTestSigstore(t *testing.T) *testSigstore { + t.Helper() + ts := &testSigstore{} + ts.rootKey = mustECKey(t, elliptic.P384()) + ts.interKey = mustECKey(t, elliptic.P384()) + ts.rekorKey = mustECKey(t, elliptic.P256()) + notBefore := time.Now().Add(-24 * time.Hour) + notAfter := time.Now().Add(365 * 24 * time.Hour) + + ts.root = mustCert(t, &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{Organization: []string{"test"}, CommonName: "test-root"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + }, nil, &ts.rootKey.PublicKey, ts.rootKey) + ts.inter = mustCert(t, &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{Organization: []string{"test"}, CommonName: "test-intermediate"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLenZero: true, + }, ts.root, &ts.interKey.PublicKey, ts.rootKey) + + der, err := x509.MarshalPKIXPublicKey(&ts.rekorKey.PublicKey) + if err != nil { + t.Fatal(err) + } + id := sha256.Sum256(der) + ts.logID = id[:] + trust, err := newSigstoreTrust( + []string{string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ts.root.Raw}))}, + []string{string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ts.inter.Raw}))}, + []string{string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))}, + ) + if err != nil { + t.Fatal(err) + } + ts.trust = trust + return ts +} + +// install points upgradeTrust at this test CA until the test ends. +func (ts *testSigstore) install(t *testing.T) { + t.Helper() + prev := upgradeTrust + upgradeTrust = ts.trust + t.Cleanup(func() { upgradeTrust = prev }) +} + +func mustECKey(t *testing.T, c elliptic.Curve) *ecdsa.PrivateKey { + t.Helper() + k, err := ecdsa.GenerateKey(c, rand.Reader) + if err != nil { + t.Fatal(err) + } + return k +} + +func mustCert(t *testing.T, tmpl, parent *x509.Certificate, pub *ecdsa.PublicKey, signer *ecdsa.PrivateKey) *x509.Certificate { + t.Helper() + if parent == nil { + parent = tmpl + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, parent, pub, signer) + if err != nil { + t.Fatal(err) + } + c, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return c +} + +func utf8Ext(t *testing.T, s string) []byte { + t.Helper() + b, err := asn1.MarshalWithParams(s, "utf8") + if err != nil { + t.Fatal(err) + } + return b +} + +// testBundleOpts selects how a test bundle deviates from a valid one. +type testBundleOpts struct { + tag string + san string // overrides the workflow identity + issuer string // overrides the OIDC issuer (both extensions) + legacyIssuer bool // only the legacy raw issuer extension + repo string // overrides the source-repository extension + untrustedCA bool // leaf issued by a CA outside the trust + leafExpired bool // leaf validity ends before the integration time + badSET bool // corrupt the signed entry timestamp + noSET bool + noProof bool + badProof bool // wrong audit path + badCheckpoint bool // checkpoint signed by another key + signOther []byte // message signature over different bytes + bodyDigest string // overrides the digest recorded in the log body + otherLogKey bool // log ID not in the trust + kind string // overrides kindVersion.kind + mediaType string + noMessageSig bool + noCert bool + integratedTime int64 // overrides the integration time +} + +func (ts *testSigstore) bundle(t *testing.T, artifact []byte, o testBundleOpts) []byte { + t.Helper() + san := o.san + if san == "" { + san = upgradeWorkflow + "@refs/tags/" + o.tag + } + sanURL, err := url.Parse(san) + if err != nil { + t.Fatal(err) + } + issuer := o.issuer + if issuer == "" { + issuer = upgradeOIDCIssuer + } + repo := o.repo + if repo == "" { + repo = upgradeSourceRepo + } + integrated := time.Now().Add(-time.Minute) + if o.integratedTime != 0 { + integrated = time.Unix(o.integratedTime, 0) + } + leafNotBefore := integrated.Add(-time.Minute) + leafNotAfter := integrated.Add(9 * time.Minute) + if o.leafExpired { + leafNotAfter = integrated.Add(-time.Second) + } + + exts := []pkix.Extension{{Id: oidFulcioIssuerLegacy, Value: []byte(issuer)}} + if !o.legacyIssuer { + exts = append(exts, + pkix.Extension{Id: oidFulcioIssuer, Value: utf8Ext(t, issuer)}, + pkix.Extension{Id: oidFulcioSourceRepo, Value: utf8Ext(t, repo)}) + } + leafKey := mustECKey(t, elliptic.P256()) + parent, parentKey := ts.inter, ts.interKey + if o.untrustedCA { + other := newTestSigstore(t) + parent, parentKey = other.inter, other.interKey + } + leaf := mustCert(t, &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + NotBefore: leafNotBefore, + NotAfter: leafNotAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + URIs: []*url.URL{sanURL}, + ExtraExtensions: exts, + }, parent, &leafKey.PublicKey, parentKey) + + digest := sha256.Sum256(artifact) + signed := digest + if o.signOther != nil { + signed = sha256.Sum256(o.signOther) + } + sig, err := ecdsa.SignASN1(rand.Reader, leafKey, signed[:]) + if err != nil { + t.Fatal(err) + } + + bodyDigest := hex.EncodeToString(digest[:]) + if o.bodyDigest != "" { + bodyDigest = o.bodyDigest + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf.Raw}) + body := fmt.Sprintf(`{"apiVersion":"0.0.1","kind":"hashedrekord","spec":{"data":{"hash":{"algorithm":"sha256","value":%q}},"signature":{"content":%q,"publicKey":{"content":%q}}}}`, + bodyDigest, base64.StdEncoding.EncodeToString(sig), base64.StdEncoding.EncodeToString(certPEM)) + + logKey, logID := ts.rekorKey, ts.logID + if o.otherLogKey { + logKey = mustECKey(t, elliptic.P256()) + der, _ := x509.MarshalPKIXPublicKey(&logKey.PublicKey) + id := sha256.Sum256(der) + logID = id[:] + } + const logIndex = 4242 + setPayload := fmt.Sprintf(`{"body":%q,"integratedTime":%d,"logID":%q,"logIndex":%d}`, + base64.StdEncoding.EncodeToString([]byte(body)), integrated.Unix(), hex.EncodeToString(logID), logIndex) + setHash := sha256.Sum256([]byte(setPayload)) + set, err := ecdsa.SignASN1(rand.Reader, logKey, setHash[:]) + if err != nil { + t.Fatal(err) + } + if o.badSET { + set[len(set)-1] ^= 0xff + } + + // A three-leaf tree with the entry at index 2. + l0 := sha256.Sum256([]byte{0, 'a'}) + l1 := sha256.Sum256([]byte{0, 'b'}) + l2 := sha256.Sum256(append([]byte{0}, body...)) + h01 := hashChildren(l0[:], l1[:]) + root := hashChildren(h01, l2[:]) + path := [][]byte{h01} + if o.badProof { + path = [][]byte{l0[:]} + } + cpText := fmt.Sprintf("test-log - 1\n3\n%s\n", base64.StdEncoding.EncodeToString(root)) + cpKey := logKey + if o.badCheckpoint { + cpKey = mustECKey(t, elliptic.P256()) + } + cpHash := sha256.Sum256([]byte(cpText)) + cpSig, err := ecdsa.SignASN1(rand.Reader, cpKey, cpHash[:]) + if err != nil { + t.Fatal(err) + } + envelope := cpText + "\n— test-log " + base64.StdEncoding.EncodeToString(append(append([]byte{}, logID[:4]...), cpSig...)) + "\n" + + kind := o.kind + if kind == "" { + kind = "hashedrekord" + } + entry := map[string]any{ + "logIndex": strconv.Itoa(logIndex), + "logId": map[string]any{"keyId": logID}, + "kindVersion": map[string]any{"kind": kind, "version": "0.0.1"}, + "integratedTime": strconv.FormatInt(integrated.Unix(), 10), + "canonicalizedBody": []byte(body), + } + if !o.noSET { + entry["inclusionPromise"] = map[string]any{"signedEntryTimestamp": set} + } + if !o.noProof { + entry["inclusionProof"] = map[string]any{ + "logIndex": "2", + "rootHash": root, + "treeSize": "3", + "hashes": path, + "checkpoint": map[string]any{"envelope": envelope}, + } + } + media := o.mediaType + if media == "" { + media = "application/vnd.dev.sigstore.bundle.v0.3+json" + } + vm := map[string]any{"tlogEntries": []any{entry}} + if !o.noCert { + vm["certificate"] = map[string]any{"rawBytes": leaf.Raw} + } + b := map[string]any{"mediaType": media, "verificationMaterial": vm} + if !o.noMessageSig { + b["messageSignature"] = map[string]any{ + "messageDigest": map[string]any{"algorithm": "SHA2_256", "digest": digest[:]}, + "signature": sig, + } + } + out, err := json.Marshal(b) + if err != nil { + t.Fatal(err) + } + return out +} + +// The verifier accepts the real signed checksums.txt of a published release +// with the production trust material, and pins it to that release's tag. +func TestVerifyChecksumsBundle_RealRelease(t *testing.T) { + bundle, err := os.ReadFile(filepath.Join("testdata", "upgrade", "checksums.txt.bundle")) + if err != nil { + t.Fatal(err) + } + sums, err := os.ReadFile(filepath.Join("testdata", "upgrade", "checksums.txt")) + if err != nil { + t.Fatal(err) + } + trust := mustDefaultSigstoreTrust() + if err := verifyChecksumsBundle(trust, bundle, sums, "v1.20.3"); err != nil { + t.Fatalf("real v1.20.3 bundle rejected: %v", err) + } + if err := verifyChecksumsBundle(trust, bundle, sums, "v1.20.4"); err == nil || !strings.Contains(err.Error(), "signing identity") { + t.Errorf("bundle accepted for another tag: %v", err) + } + tampered := append([]byte{}, sums...) + tampered[0] ^= 1 + if err := verifyChecksumsBundle(trust, bundle, tampered, "v1.20.3"); err == nil { + t.Error("tampered checksums.txt accepted") + } + // The real bundle under a foreign trust root must fail. + if err := verifyChecksumsBundle(newTestSigstore(t).trust, bundle, sums, "v1.20.3"); err == nil { + t.Error("real bundle accepted under an unrelated trust root") + } +} + +func TestVerifyChecksumsBundle_Synthetic(t *testing.T) { + ts := newTestSigstore(t) + artifact := []byte("0123 bodek_9.9.9_linux_amd64.tar.gz\n") + const tag = "v9.9.9" + + if err := verifyChecksumsBundle(ts.trust, ts.bundle(t, artifact, testBundleOpts{tag: tag}), artifact, tag); err != nil { + t.Fatalf("valid bundle rejected: %v", err) + } + if err := verifyChecksumsBundle(ts.trust, ts.bundle(t, artifact, testBundleOpts{tag: tag, noProof: true}), artifact, tag); err != nil { + t.Fatalf("valid bundle without inclusion proof rejected: %v", err) + } + if err := verifyChecksumsBundle(ts.trust, ts.bundle(t, artifact, testBundleOpts{tag: tag, legacyIssuer: true}), artifact, tag); err != nil { + t.Fatalf("legacy-issuer certificate rejected: %v", err) + } + + cases := []struct { + name string + opts testBundleOpts + artifact []byte + tag string + want string + }{ + {"tampered checksums", testBundleOpts{}, []byte("tampered"), tag, "digest"}, + {"wrong tag in SAN", testBundleOpts{san: upgradeWorkflow + "@refs/tags/v0.0.1"}, artifact, tag, "signing identity"}, + {"other workflow", testBundleOpts{san: upgradeSourceRepo + "/.github/workflows/evil.yml@refs/tags/" + tag}, artifact, tag, "signing identity"}, + {"branch ref", testBundleOpts{san: upgradeWorkflow + "@refs/heads/main"}, artifact, tag, "signing identity"}, + {"wrong issuer", testBundleOpts{issuer: "https://accounts.google.com"}, artifact, tag, "OIDC issuer"}, + {"wrong legacy issuer", testBundleOpts{issuer: "https://accounts.google.com", legacyIssuer: true}, artifact, tag, "OIDC issuer"}, + {"wrong source repo", testBundleOpts{repo: "https://github.com/evil/bodek"}, artifact, tag, "source repository"}, + {"untrusted root", testBundleOpts{untrustedCA: true}, artifact, tag, "chain"}, + {"expired at integration", testBundleOpts{leafExpired: true}, artifact, tag, "chain"}, + {"bad SET", testBundleOpts{badSET: true}, artifact, tag, "signed entry timestamp"}, + {"no SET", testBundleOpts{noSET: true}, artifact, tag, "signed entry timestamp"}, + {"bad inclusion proof", testBundleOpts{badProof: true}, artifact, tag, "inclusion proof"}, + {"bad checkpoint", testBundleOpts{badCheckpoint: true}, artifact, tag, "checkpoint"}, + {"signature over other bytes", testBundleOpts{signOther: []byte("x")}, artifact, tag, "does not verify"}, + {"log body digest differs", testBundleOpts{bodyDigest: strings.Repeat("0", 64)}, artifact, tag, "different digest"}, + {"untrusted log", testBundleOpts{otherLogKey: true}, artifact, tag, "not trusted"}, + {"unsupported kind", testBundleOpts{kind: "intoto"}, artifact, tag, "unsupported"}, + {"media type", testBundleOpts{mediaType: "application/json"}, artifact, tag, "media type"}, + {"no message signature", testBundleOpts{noMessageSig: true}, artifact, tag, "message signature"}, + {"no certificate", testBundleOpts{noCert: true}, artifact, tag, "certificate"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + c.opts.tag = tag + err := verifyChecksumsBundle(ts.trust, ts.bundle(t, artifact, c.opts), c.artifact, c.tag) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("err = %v, want containing %q", err, c.want) + } + }) + } + + if err := verifyChecksumsBundle(ts.trust, []byte("{"), artifact, tag); err == nil { + t.Error("malformed bundle accepted") + } + if err := verifyChecksumsBundle(nil, ts.bundle(t, artifact, testBundleOpts{tag: tag}), artifact, tag); err == nil { + t.Error("nil trust accepted") + } +} + +// A body rewritten after logging (another certificate or signature) breaks +// the binding between the bundle and the log entry. +func TestVerifyChecksumsBundle_LogBodyBinding(t *testing.T) { + ts := newTestSigstore(t) + artifact := []byte("sums\n") + const tag = "v1.2.3" + a := ts.bundle(t, artifact, testBundleOpts{tag: tag}) + b := ts.bundle(t, artifact, testBundleOpts{tag: tag}) + var ba, bb map[string]any + if err := json.Unmarshal(a, &ba); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &bb); err != nil { + t.Fatal(err) + } + // Bundle a's certificate and signature with bundle b's log entry. + ba["verificationMaterial"].(map[string]any)["tlogEntries"] = bb["verificationMaterial"].(map[string]any)["tlogEntries"] + mixed, _ := json.Marshal(ba) + if err := verifyChecksumsBundle(ts.trust, mixed, artifact, tag); err == nil || !strings.Contains(err.Error(), "different") { + t.Fatalf("mixed bundle: err = %v", err) + } + // Two log entries are refused. + vm := ba["verificationMaterial"].(map[string]any) + vm["tlogEntries"] = []any{vm["tlogEntries"].([]any)[0], vm["tlogEntries"].([]any)[0]} + two, _ := json.Marshal(ba) + if err := verifyChecksumsBundle(ts.trust, two, artifact, tag); err == nil || !strings.Contains(err.Error(), "exactly one") { + t.Fatalf("two entries: err = %v", err) + } +} + +func TestRFC6962RootFromPath(t *testing.T) { + leaf := sha256.Sum256([]byte{0, 'x'}) + if _, err := rfc6962RootFromPath(0, 2, leaf[:], nil); err == nil { + t.Error("short path accepted") + } + if _, err := rfc6962RootFromPath(0, 1, leaf[:], [][]byte{leaf[:]}); err == nil { + t.Error("long path accepted") + } + if _, err := rfc6962RootFromPath(0, 2, leaf[:], [][]byte{{1, 2}}); err == nil { + t.Error("bad hash length accepted") + } + got, err := rfc6962RootFromPath(0, 1, leaf[:], nil) + if err != nil || !bytes.Equal(got, leaf[:]) { + t.Errorf("single-leaf root = %x, %v", got, err) + } +} + +func TestVerifyCheckpoint_Malformed(t *testing.T) { + ts := newTestSigstore(t) + root := make([]byte, 32) + for _, env := range []string{"", "origin\n3\n", "origin\n3\n\n— x y\n", "o\n4\n" + base64.StdEncoding.EncodeToString(root) + "\n\n— x zz\n"} { + if err := verifyCheckpoint(&ts.rekorKey.PublicKey, ts.logID, env, 3, root); err == nil { + t.Errorf("checkpoint %q accepted", env) + } + } +} + +func TestDefaultSigstoreTrust(t *testing.T) { + trust := mustDefaultSigstoreTrust() + const rekorLogID = "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d" + if _, ok := trust.rekorKeys[rekorLogID]; !ok { + t.Errorf("embedded Rekor key id missing; have %v", trust.rekorKeys) + } + if _, err := newSigstoreTrust([]string{"x"}, nil, nil); err == nil { + t.Error("bad root accepted") + } + if _, err := newSigstoreTrust(nil, []string{"x"}, nil); err == nil { + t.Error("bad intermediate accepted") + } + if _, err := newSigstoreTrust(nil, nil, []string{"x"}); err == nil { + t.Error("bad key PEM accepted") + } + if _, err := newSigstoreTrust(nil, nil, []string{"-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----\n"}); err == nil { + t.Error("bad key accepted") + } +} diff --git a/cmd/bodek/upgrade_test.go b/cmd/bodek/upgrade_test.go index 0e6763e..ce67fad 100644 --- a/cmd/bodek/upgrade_test.go +++ b/cmd/bodek/upgrade_test.go @@ -68,14 +68,31 @@ func sha256Hex(data []byte) string { } // upgradeServer serves a fake latest release: the API JSON, a tar.gz archive -// holding newBinary, and a checksums.txt matching it. If badChecksum is set, -// checksums.txt serves a wrong digest for the archive. +// holding newBinary, a checksums.txt matching it, and a checksums.txt.bundle +// signed for tag by an in-test Sigstore installed as upgradeTrust. If +// badChecksum is set, checksums.txt serves a wrong digest for the archive. func upgradeServer(t *testing.T, tag, assetName string, archive []byte, badChecksum bool) *httptest.Server { + t.Helper() + ts := newTestSigstore(t) + ts.install(t) + return upgradeServerWith(t, tag, assetName, archive, badChecksum, func(sums []byte) []byte { + return ts.bundle(t, sums, testBundleOpts{tag: tag}) + }) +} + +// upgradeServerWith is upgradeServer with the bundle built by sign over the +// served checksums.txt; a nil sign publishes no bundle asset. +func upgradeServerWith(t *testing.T, tag, assetName string, archive []byte, badChecksum bool, sign func(sums []byte) []byte) *httptest.Server { t.Helper() digest := sha256Hex(archive) if badChecksum { digest = sha256Hex([]byte("something else")) } + sums := []byte(fmt.Sprintf("%s %s\n", digest, assetName)) + bundleAsset := "" + if sign != nil { + bundleAsset = `,{"name":"checksums.txt.bundle","browser_download_url":%[5]q}` + } mux := http.NewServeMux() var base string mux.HandleFunc("/latest", func(w http.ResponseWriter, r *http.Request) { @@ -83,17 +100,23 @@ func upgradeServer(t *testing.T, tag, assetName string, archive []byte, badCheck t.Errorf("expected User-Agent bodek-updater, got %q", got) } w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"tag_name":%q,"assets":[`+ - `{"name":%q,"browser_download_url":%q},`+ - `{"name":"checksums.txt","browser_download_url":%q}]}`, - tag, assetName, base+"/files/"+assetName, base+"/files/checksums.txt") + _, _ = fmt.Fprintf(w, `{"tag_name":%[1]q,"assets":[`+ + `{"name":%[2]q,"browser_download_url":%[3]q},`+ + `{"name":"checksums.txt","browser_download_url":%[4]q}`+bundleAsset+`]}`, + tag, assetName, base+"/files/"+assetName, base+"/files/checksums.txt", base+"/files/checksums.txt.bundle") }) mux.HandleFunc("/files/"+assetName, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(archive) }) mux.HandleFunc("/files/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { - _, _ = fmt.Fprintf(w, "%s %s\n", digest, assetName) + _, _ = w.Write(sums) }) + if sign != nil { + bundle := sign(sums) + mux.HandleFunc("/files/checksums.txt.bundle", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(bundle) + }) + } srv := httptest.NewServer(mux) base = srv.URL t.Cleanup(srv.Close) @@ -127,7 +150,7 @@ func TestUpgradeHappyPath(t *testing.T) { exe := installFake(t, []byte("old bodek")) var out bytes.Buffer - if err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, &out); err != nil { + if err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, upgradeOptions{}, &out); err != nil { t.Fatalf("upgrade returned error: %v", err) } if got := readFile(t, exe); !bytes.Equal(got, newBinary) { @@ -143,6 +166,104 @@ func TestUpgradeHappyPath(t *testing.T) { if !strings.Contains(out.String(), "upgraded bodek v0.0.1 → v9.9.9") { t.Errorf("missing upgrade confirmation in output: %q", out.String()) } + if !strings.Contains(out.String(), "verified Sigstore signature: "+upgradeWorkflow+"@refs/tags/v9.9.9") { + t.Errorf("missing signature confirmation in output: %q", out.String()) + } +} + +// A release whose checksums.txt carries no Sigstore signature must not be +// installed: a matching checksum from the same release proves nothing when +// the release itself was tampered with. +func TestUpgradeRefusesUnsignedChecksums(t *testing.T) { + setVersion(t, "0.0.1") + assetName := archiveName("9.9.9", runtime.GOOS, runtime.GOARCH) + srv := upgradeServerWith(t, "v9.9.9", assetName, buildTarGz(t, "bodek", []byte("attacker")), false, nil) + exe := installFake(t, []byte("old bodek")) + + err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, upgradeOptions{}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "signature") { + t.Fatalf("expected a signature refusal, got %v", err) + } + if got := readFile(t, exe); string(got) != "old bodek" { + t.Errorf("unsigned release was installed: %q", got) + } +} + +// --skip-signature falls back to checksum-only for a release without a +// bundle, and says so. +func TestUpgradeSkipSignatureInstallsUnsigned(t *testing.T) { + setVersion(t, "0.0.1") + assetName := archiveName("9.9.9", runtime.GOOS, runtime.GOARCH) + srv := upgradeServerWith(t, "v9.9.9", assetName, buildTarGz(t, "bodek", []byte("new")), false, nil) + exe := installFake(t, []byte("old bodek")) + + var out bytes.Buffer + if err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, upgradeOptions{skipSignature: true}, &out); err != nil { + t.Fatalf("upgrade returned error: %v", err) + } + if got := readFile(t, exe); string(got) != "new" { + t.Errorf("expected the new binary, got %q", got) + } + if !strings.Contains(out.String(), "signature verification skipped") { + t.Errorf("missing skip notice in output: %q", out.String()) + } +} + +// Bundles that do not verify for this release are refused before anything +// is installed: another tag, another workflow, an untrusted CA, or a +// signature over a different checksums.txt. +func TestUpgradeRejectsForeignBundle(t *testing.T) { + setVersion(t, "0.0.1") + assetName := archiveName("9.9.9", runtime.GOOS, runtime.GOARCH) + cases := map[string]testBundleOpts{ + "older tag": {tag: "v1.0.0"}, + "other workflow": {tag: "v9.9.9", san: upgradeSourceRepo + "/.github/workflows/evil.yml@refs/tags/v9.9.9"}, + "untrusted CA": {tag: "v9.9.9", untrustedCA: true}, + "other content": {tag: "v9.9.9", signOther: []byte("x")}, + } + for name, o := range cases { + t.Run(name, func(t *testing.T) { + ts := newTestSigstore(t) + ts.install(t) + srv := upgradeServerWith(t, "v9.9.9", assetName, buildTarGz(t, "bodek", []byte("attacker")), false, func(sums []byte) []byte { + return ts.bundle(t, sums, o) + }) + exe := installFake(t, []byte("old bodek")) + err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, upgradeOptions{}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "signature verification failed") { + t.Fatalf("expected a signature verification failure, got %v", err) + } + if got := readFile(t, exe); string(got) != "old bodek" { + t.Errorf("executable must be untouched, got %q", got) + } + }) + } +} + +func TestUpgradeBundleDownloadFailure(t *testing.T) { + setVersion(t, "0.0.1") + ts := newTestSigstore(t) + ts.install(t) + assetName := archiveName("9.9.9", runtime.GOOS, runtime.GOARCH) + srv := upgradeServerWith(t, "v9.9.9", assetName, buildTarGz(t, "bodek", []byte("new")), false, nil) + exe := installFake(t, []byte("old bodek")) + // Advertise a bundle URL the server answers with 404. + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, `{"tag_name":"v9.9.9","assets":[`+ + `{"name":%q,"browser_download_url":%q},`+ + `{"name":"checksums.txt","browser_download_url":%q},`+ + `{"name":"checksums.txt.bundle","browser_download_url":%q}]}`, + assetName, srv.URL+"/files/"+assetName, srv.URL+"/files/checksums.txt", srv.URL+"/files/missing.bundle") + })) + t.Cleanup(api.Close) + + err := upgrade(context.Background(), srv.Client(), api.URL, exe, upgradeOptions{}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "404") { + t.Fatalf("expected a bundle download failure, got %v", err) + } + if got := readFile(t, exe); string(got) != "old bodek" { + t.Errorf("executable must be untouched, got %q", got) + } } func TestUpgradeAlreadyUpToDate(t *testing.T) { @@ -152,7 +273,7 @@ func TestUpgradeAlreadyUpToDate(t *testing.T) { exe := installFake(t, []byte("current bodek")) var out bytes.Buffer - if err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, &out); err != nil { + if err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, upgradeOptions{}, &out); err != nil { t.Fatalf("upgrade returned error: %v", err) } if got, want := out.String(), "bodek is already up to date (v9.9.9)\n"; got != want { @@ -170,7 +291,7 @@ func TestUpgradeSkipsWhenLocalIsNewer(t *testing.T) { exe := installFake(t, []byte("current bodek")) var out bytes.Buffer - if err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, &out); err != nil { + if err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, upgradeOptions{}, &out); err != nil { t.Fatalf("upgrade returned error: %v", err) } if !strings.Contains(out.String(), "already up to date") { @@ -188,7 +309,7 @@ func TestUpgradeFromDevBuild(t *testing.T) { exe := installFake(t, []byte("dev build")) var out bytes.Buffer - if err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, &out); err != nil { + if err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, upgradeOptions{}, &out); err != nil { t.Fatalf("upgrade returned error: %v", err) } if !strings.Contains(out.String(), "unstamped (dev)") { @@ -205,7 +326,7 @@ func TestUpgradeChecksumMismatch(t *testing.T) { srv := upgradeServer(t, "v9.9.9", assetName, buildTarGz(t, "bodek", []byte("new")), true) exe := installFake(t, []byte("old bodek")) - err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, &bytes.Buffer{}) + err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, upgradeOptions{}, &bytes.Buffer{}) if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { t.Fatalf("expected checksum mismatch error, got %v", err) } @@ -221,7 +342,7 @@ func TestUpgradeMissingPlatformAsset(t *testing.T) { srv := upgradeServer(t, "v9.9.9", otherName, buildTarGz(t, "bodek", []byte("new")), false) exe := installFake(t, []byte("old bodek")) - err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, &bytes.Buffer{}) + err := upgrade(context.Background(), srv.Client(), srv.URL+"/latest", exe, upgradeOptions{}, &bytes.Buffer{}) if err == nil || !strings.Contains(err.Error(), "no asset") { t.Fatalf("expected missing-asset error, got %v", err) } @@ -280,3 +401,4 @@ func TestReplaceExecutableFollowsSymlink(t *testing.T) { t.Errorf("symlink target changed: %q → %q", realExe, linkDest) } } + diff --git a/cmd/bodek/version.go b/cmd/bodek/version.go index 6e44756..7bf9261 100644 --- a/cmd/bodek/version.go +++ b/cmd/bodek/version.go @@ -3,6 +3,7 @@ package main import ( "fmt" "io" + "os" "runtime/debug" "strings" ) @@ -23,6 +24,21 @@ func currentVersion() string { return "dev" } +const upgradeUsage = `Usage: bodek upgrade [--skip-signature] + +Upgrade bodek to the latest GitHub release. The release's checksums.txt is +verified against its Sigstore signature (the release workflow's keyless +certificate for that exact tag, logged in Rekor); the archive matching the +current OS/architecture is then downloaded, verified against checksums.txt +(SHA-256), and installed atomically over the running binary. Any +verification failure refuses the upgrade. + + --skip-signature UNSAFE: skip the Sigstore check and trust checksums.txt + from the release itself. Only for when this binary's + embedded Sigstore trust root can no longer verify new + releases; verify the download yourself with cosign. +` + // handleSubcommand runs the bare subcommands (`bodek version`, // `bodek upgrade`) before flag parsing kicks in. It reports whether args // named a subcommand; anything else falls through to the normal TUI path. @@ -39,7 +55,22 @@ func handleSubcommand(args []string, stdout io.Writer) (handled bool, err error) } return true, nil case "upgrade": - return true, runUpgrade(stdout) + var opts upgradeOptions + for _, a := range args[1:] { + switch a { + case "--skip-signature": + opts.skipSignature = true + case "--help", "-h": + _, _ = fmt.Fprint(stdout, upgradeUsage) + return true, nil + default: + return true, fmt.Errorf("unknown flag %q for upgrade", a) + } + } + if opts.skipSignature { + _, _ = fmt.Fprintln(os.Stderr, "warning: --skip-signature: the release signature is NOT verified; a tampered release would be installed") + } + return true, runUpgrade(opts, stdout) } return false, nil } diff --git a/cmd/bodek/version_test.go b/cmd/bodek/version_test.go index f70b26f..9f62ea9 100644 --- a/cmd/bodek/version_test.go +++ b/cmd/bodek/version_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "io" + "strings" "testing" ) @@ -62,3 +63,18 @@ func TestHandleSubcommandUnhandled(t *testing.T) { } } } + +func TestHandleSubcommandUpgradeFlags(t *testing.T) { + var out bytes.Buffer + handled, err := handleSubcommand([]string{"upgrade", "--help"}, &out) + if err != nil || !handled { + t.Fatalf("--help: handled=%v err=%v", handled, err) + } + if !strings.Contains(out.String(), "--skip-signature") || !strings.Contains(out.String(), "Sigstore") { + t.Errorf("upgrade usage missing signature docs: %q", out.String()) + } + handled, err = handleSubcommand([]string{"upgrade", "--bogus"}, io.Discard) + if !handled || err == nil || !strings.Contains(err.Error(), `unknown flag "--bogus"`) { + t.Errorf("unknown flag: handled=%v err=%v", handled, err) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index d17aa1d..72ecc5a 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -133,7 +133,10 @@ func tagFromURL(u *url.URL) (string, bool) { func conventionalAssets(tag string) map[string]string { bare := strings.TrimPrefix(tag, "v") base := "https://github.com/BackendStack21/bodek/releases/download/" + tag + "/" - assets := map[string]string{"checksums.txt": base + "checksums.txt"} + assets := map[string]string{ + "checksums.txt": base + "checksums.txt", + "checksums.txt.bundle": base + "checksums.txt.bundle", + } for _, goos := range []string{"darwin", "linux", "windows"} { for _, arch := range []string{"amd64", "arm64"} { ext := "tar.gz" diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 2ce399a..2322476 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -132,7 +132,7 @@ func TestLatestReleaseFallsBackOnForbidden(t *testing.T) { if tag != "v9.9.9" { t.Errorf("tag = %q, want v9.9.9", tag) } - if assets["checksums.txt"] == "" || assets["bodek_9.9.9_darwin_arm64.tar.gz"] == "" { + if assets["checksums.txt"] == "" || assets["checksums.txt.bundle"] == "" || assets["bodek_9.9.9_darwin_arm64.tar.gz"] == "" { t.Errorf("conventional assets missing: %v", assets) } } From 728a53d278205ba80250b7501bc5d222338b961f Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 18 Oct 2026 08:52:49 +0200 Subject: [PATCH 2/4] fix(upgrade): name oversized release assets instead of truncating A body past the 256 MiB cap was silently truncated and surfaced as a misleading checksum or signature failure. Co-Authored-By: Claude Opus 5.5 --- cmd/bodek/upgrade.go | 10 +++++++++- cmd/bodek/upgrade_test.go | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/cmd/bodek/upgrade.go b/cmd/bodek/upgrade.go index 76f7e1c..8b113cc 100644 --- a/cmd/bodek/upgrade.go +++ b/cmd/bodek/upgrade.go @@ -138,6 +138,9 @@ func archiveName(tag, goos, goarch string) string { // fail every time under the short API budget. const downloadTimeout = 10 * time.Minute +// maxDownload caps a release-asset body; var so tests can lower it. +var maxDownload = 256 << 20 + // download fetches url into memory. Release archives are a few MB, so a // buffered read is fine and keeps checksum verification straightforward. func download(ctx context.Context, client *http.Client, url string) ([]byte, error) { @@ -160,10 +163,15 @@ func download(ctx context.Context, client *http.Client, url string) ([]byte, err if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("download %s: unexpected status %s", url, resp.Status) } - data, err := io.ReadAll(io.LimitReader(resp.Body, 256<<20)) + data, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxDownload)+1)) if err != nil { return nil, fmt.Errorf("read %s: %w", url, err) } + // Name an oversized asset instead of letting the truncated body surface + // as a confusing checksum or signature failure. + if len(data) > maxDownload { + return nil, fmt.Errorf("download %s: asset exceeds %d MiB", url, maxDownload>>20) + } return data, nil } diff --git a/cmd/bodek/upgrade_test.go b/cmd/bodek/upgrade_test.go index ce67fad..32451b2 100644 --- a/cmd/bodek/upgrade_test.go +++ b/cmd/bodek/upgrade_test.go @@ -402,3 +402,22 @@ func TestReplaceExecutableFollowsSymlink(t *testing.T) { } } +// An asset past the download cap is named as oversized rather than +// truncated into a misleading checksum or signature failure. +func TestDownloadRejectsOversizedAsset(t *testing.T) { + prev := maxDownload + maxDownload = 8 + t.Cleanup(func() { maxDownload = prev }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("0123456789")) + })) + t.Cleanup(srv.Close) + + if _, err := download(context.Background(), srv.Client(), srv.URL); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("expected an oversized-asset error, got %v", err) + } + maxDownload = 10 + if data, err := download(context.Background(), srv.Client(), srv.URL); err != nil || string(data) != "0123456789" { + t.Fatalf("asset at the cap: data=%q err=%v", data, err) + } +} From 11a8063803fbf9fac3c75847e9428e702d34dd22 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 18 Oct 2026 08:52:49 +0200 Subject: [PATCH 3/4] docs: document signed upgrades and --skip-signature Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- README.md | 19 +++++++++++++++++-- cmd/bodek/main.go | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c8d73bb..b619743 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Keep it that way. | Path | Responsibility | |------|----------------| -| `cmd/bodek` | CLI entry point: flags, lifecycle, `version` / `upgrade` subcommands, connection `diagnose`, and orphan-watchdog re-exec | +| `cmd/bodek` | CLI entry point: flags, lifecycle, `version` / `upgrade` subcommands (Sigstore-verified `checksums.txt`, `upgrade_sigstore.go`), connection `diagnose`, and orphan-watchdog re-exec | | `internal/server` | Launch / attach to `odek serve`, resolve the auth token, progress-aware shutdown with escalation countdown | | `internal/client` | odek serve WebSocket protocol (transport + REST + decoding) | | `internal/tokens` | Local persistence of per-session auth tokens | diff --git a/README.md b/README.md index 99c885a..f53b2c6 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,13 @@ curl -fsSL https://bodek.21no.de/install.sh | sh You still need a provider API key for the engine (`DEEPSEEK_API_KEY` / `ZAI_API_KEY` — see odek's [PROVIDERS.md](https://github.com/BackendStack21/odek/blob/main/docs/PROVIDERS.md) and its [install instructions](https://github.com/BackendStack21/odek)). -Later, `bodek upgrade` downloads and installs the latest release the same way. +Later, `bodek upgrade` downloads and installs the latest release: it verifies +the release's `checksums.txt` against its Sigstore signature (the release +workflow's keyless certificate for that exact tag, logged in Rekor), then the +archive against `checksums.txt` (SHA-256), and refuses to install on any failure. +The signature pins the tag being installed, not freshness: a compromised release +feed can still offer an older genuine release that is newer than the installed +one (a `dev` build accepts whichever signed release the feed names). On Windows, download the `windows_amd64` (or `arm64`) `.zip` from the [releases page](https://github.com/BackendStack21/bodek/releases) and put @@ -121,7 +127,8 @@ bodek --plain # linear mode: transcript to s bodek --reduce-motion # calmer transcript: clock lane at 2s, no accent pulses bodek -- --prompt-caching # pass extra flags through to `odek serve` bodek version # print the bodek version -bodek upgrade # download and install the latest release +bodek upgrade # download and install the latest release (Sigstore + SHA-256 verified) +bodek upgrade --skip-signature # UNSAFE: checksum-only, when the embedded Sigstore root is stale ``` `odek serve` protects its WebSocket and REST APIs with a per-instance token it @@ -695,6 +702,14 @@ never become the weakest link: - **`bodek upgrade` returns 403** — GitHub's anonymous API quota (or an IP block) rejected the request. Retry, or set `GITHUB_TOKEN` / `GH_TOKEN` (`gh auth token`). bodek also falls back to the HTML latest-release page. +- **`bodek upgrade` reports `signature verification failed`** — the release's + `checksums.txt.bundle` did not verify for that tag, so nothing was installed. + The Sigstore trust root is compiled in: after a Sigstore root or Rekor key + rotation an older bodek cannot verify newer releases. Verify the release + yourself (`cosign verify-blob --bundle checksums.txt.bundle + --certificate-identity https://github.com/BackendStack21/bodek/.github/workflows/release.yml@refs/tags/ + --certificate-oidc-issuer https://token.actions.githubusercontent.com checksums.txt`) + and then run `bodek upgrade --skip-signature`, or reinstall with the install script. - **Auth errors when attaching** — copy the *full* token URL `odek serve` printed (`--url 'http://…/?token=…'`) or pass the token via `--token`. Spawned instances adopt the token automatically. diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index 6e99fbe..aa45341 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -146,7 +146,7 @@ func parseConfig(args []string, output io.Writer) (config, error) { _, _ = fmt.Fprintf(fs.Output(), "A terminal interface for the odek agent.\n\n") _, _ = fmt.Fprintf(fs.Output(), "Commands:\n") _, _ = fmt.Fprintf(fs.Output(), " version print the bodek version\n") - _, _ = fmt.Fprintf(fs.Output(), " upgrade download and install the latest release\n\n") + _, _ = fmt.Fprintf(fs.Output(), " upgrade download, verify (Sigstore + SHA-256), and install the latest release\n\n") _, _ = fmt.Fprintf(fs.Output(), "Options:\n") fs.PrintDefaults() _, _ = fmt.Fprintf(fs.Output(), "\nExamples:\n") From f5d4d985378f4134c67a417d4d30e9096b46a9c8 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 18 Oct 2026 08:52:49 +0200 Subject: [PATCH 4/4] ci(release): note the bundle shape bodek upgrade verifies Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c573dc9..4a1954a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,9 @@ jobs: - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: cosign-release: v3.1.3 # pinned: a future cosign major must not break a tagged release mid-flight + # `bodek upgrade` (cmd/bodek/upgrade_sigstore.go) verifies Rekor v1 + # hashedrekord bundles with compiled-in trust roots: confirm a new + # cosign still emits that bundle shape before bumping this pin. # Keyless signing: Fulcio cert bound to this workflow's OIDC identity, # logged in Rekor. Explicit file list (not a glob of everything) so