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 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") 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..8b113cc 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 { @@ -108,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) { @@ -130,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_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..32451b2 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,23 @@ func TestReplaceExecutableFollowsSymlink(t *testing.T) { t.Errorf("symlink target changed: %q → %q", realExe, linkDest) } } + +// 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) + } +} 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) } }