From 9c1ebafce42bfbfd0043d94d7d48f00e65ae3e56 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:55:46 +0100 Subject: [PATCH 1/3] Add ARM64 self-update artifact selection --- README.md | 22 ++- docs/release-matrix.md | 8 +- docs/roadmap-decisions.md | 10 +- docs/roadmap-implementation-requirements.md | 14 +- docs/security-model.md | 9 +- internal/selfupdate/selfupdate.go | 85 +++++--- internal/selfupdate/selfupdate_test.go | 42 +++- internal/selfupdate/stage.go | 33 +++- internal/selfupdate/stage_test.go | 69 +++++-- internal/selfupdate/verify.go | 184 ++++++++++++++++-- .../verify_integration_windows_test.go | 23 +-- internal/selfupdate/verify_test.go | 165 ++++++++++++++-- 12 files changed, 549 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 2ba97cd..b964613 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ real Linux CLI/runtime in an ephemeral container | Windows 10/11 x64 + Docker Desktop (Linux containers) + PowerShell | **Supported** — this is the validated configuration | | cmd.exe invocation of shims | Works for the common cases; less battle-tested than PowerShell | | WSL2 | **Not yet supported.** The selected native-Linux frontend now has an explicit fail-closed runtime boundary; config/shim/state implementation and real Docker Desktop WSL qualification remain. See [docs/wsl.md](docs/wsl.md) | -| Windows 11 ARM64 | **CI/release-artifact qualified only, not supported yet.** Native tests/build/dispatch run on GitHub-hosted ARM64 hardware and the release workflow produces a reproducible ARM64 archive, but real Docker Desktop ARM64 E2E qualification remains | +| Windows 11 ARM64 | **CI/release-artifact/update-path qualified only, not supported yet.** Native tests/build/dispatch run on GitHub-hosted ARM64 hardware, the release workflow produces a reproducible ARM64 archive, and self-update selects and verifies that archive by `GOARCH`; real Docker Desktop ARM64 E2E qualification remains | | Linux / macOS hosts | **Not supported.** The program is Go and cross-compiles, but shim installation, path mapping and doctor checks are Windows-specific | | Windows containers | Not supported; images are Linux images | @@ -876,18 +876,19 @@ a false failure; run `cb setup` to append the current default profiles. ### Self-update release selection `cb self-update --check` is the first, read-only phase of transactional -self-update support. It compares a release-qualified Windows/amd64 build with -the latest stable release and reports the exact binary, archive, checksum and -provenance policy that later phases must verify. It does not download assets or -change any files, and development builds fail closed because their installed -version cannot be proved. +self-update support. It compares a release-qualified Windows/amd64 or +Windows/arm64 build with the latest stable release and reports the exact +artifact, archive, checksum and provenance policy that later phases must +verify. It does not download assets or change any files, and development builds +fail closed because their installed version cannot be proved. Stable selection is the default. Use `--prerelease` to select the highest canonical prerelease among the 30 most recent published releases, or `--version vX.Y.Z` to inspect one exact published release; those two selectors are mutually exclusive. Selecting a version older than the running build is rejected unless `--allow-downgrade` is explicit. The -current slice supports only Windows/amd64, matching the release artifacts. +architecture comes only from Go's native `GOARCH`; any other platform fails +explicitly before network access. ### `cb self-test --json` report format @@ -1075,3 +1076,10 @@ misinterpreted as a tool shim. Verify the exact executable or archive you download. The ARM64 archive is release-provenance coverage, not a support claim: full Windows ARM64 support still requires real Docker Desktop qualification. + +`cb self-update --check` selects the raw `cb.exe` on Windows amd64 and the +ARM64 archive on Windows arm64 directly from Go's native `GOARCH`; unsupported +architectures fail explicitly. The verifier authenticates the selected asset +before extracting the ARM64 `cb.exe`, accepts the legacy two-entry checksum +manifest for pre-ARM64 amd64 releases, and requires the canonical three-entry +manifest for dual-architecture releases. diff --git a/docs/release-matrix.md b/docs/release-matrix.md index d2c4da1..6dfbf08 100644 --- a/docs/release-matrix.md +++ b/docs/release-matrix.md @@ -173,13 +173,19 @@ ARM64, runs the full unit suite, builds a release-style `cb.exe`, and dispatches a copied `jq.exe` shim to a controlled, compiled `docker.exe` stub. That proves native management execution, argv[0] shim dispatch and tool exit-code propagation on ARM64 hardware. It still has no Docker Desktop engine, so it does not qualify -bind mounts, volumes, providers or self-update. The release workflow separately +bind mounts, volumes or providers. The release workflow separately cross-builds an architecture-specific ARM64 executable and archive, reproduces both byte-for-byte on an independent runner, checksums them and includes them in release provenance. That supply-chain coverage is not a Windows ARM64 support claim. Support remains gated on real Windows ARM64 + Docker Desktop E2E evidence. +The self-update pipeline selects the ARM64 archive from native `GOARCH`, +verifies its canonical three-entry checksum manifest and GitHub provenance, +then extracts only the exact `cb.exe` entry inside the private staging +directory. This qualifies architecture selection and artifact handling, not +Docker-backed runtime behavior or the final user-facing apply/helper flow. + So CI validates compilation and pure/unit logic on a GitHub-hosted Windows runner. The matrix is what validates the `docs/shell-contract.md` semantics on a real Windows 11 + Docker Desktop host before a release. diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index 25ae242..0b18d6f 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -196,10 +196,14 @@ known folder needed for GitHub CLI's signed-root cache, noninteractive settings, and exactly one explicit `GH_TOKEN` or `GITHUB_TOKEN`. These host paths come from Windows APIs rather than inherited variables. GitHub host, config-directory, proxy, custom-CA and other inherited settings are not passed through. It -requires the canonical two-entry `SHA256SUMS` layout, invokes +accepts the legacy two-entry `SHA256SUMS` layout for pre-ARM64 amd64 releases +and requires the canonical three-entry layout for dual-architecture releases, +invokes `gh attestation verify` with the repository, exact workflow-and-tag certificate identity, tag ref and SLSA provenance predicate fixed in argv, validates the -reported subject digest and re-hashes `cb.exe` after verification. Authenticode +reported subject digest and re-hashes the selected artifact after verification. +For ARM64, it then extracts and hashes only the exact `cb.exe` entry. +Authenticode checks run from the Windows directory with bounded, cancelable subprocesses. Its opaque result binds the exact digest for the later replacement phase; any missing verifier, policy mismatch, malformed output or file change fails closed @@ -337,7 +341,7 @@ is not completion. - **lowest priority**; - native hosted ARM64 CI is merged in PR #78; - architecture-specific release packaging is merged in PR #86; - - ARM64 self-update selection remains; + - self-update selects and verifies the ARM64 archive from native `GOARCH`; - support claim only after real Windows-on-Arm + Docker Desktop E2E. ## Dormant / recurring items diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index 87831d6..05f7c2e 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -315,12 +315,14 @@ Desktop in Linux-container mode. PR #78 shipped native hosted ARM64 CI for non-Docker qualification. PR #86 shipped the architecture-specific ARM64 archive, checksum coverage, independent byte-for-byte reproduction and release provenance while preserving existing -amd64 asset names. PR #76's self-update foundation still deliberately rejects -architectures other than Windows/amd64. ARM64 install/update selection and real -Windows-on-Arm + Docker Desktop qualification therefore remain; CI and a -provenanced archive alone are not a full support claim. The first three bullets -below are the shipped PR #86 contract. Install/update selection and the -hardware-backed qualification record remain. +amd64 asset names. PR #76's self-update foundation originally rejected +architectures other than Windows/amd64. The update pipeline now selects the +ARM64 archive from native `GOARCH`, verifies its checksum and provenance before +exact extraction, and preserves legacy amd64 release compatibility. Real +Windows-on-Arm + Docker Desktop qualification remains; CI, update selection and +a provenanced archive are not a full support claim. The first three bullets +below are the shipped PR #86 contract. The hardware-backed qualification record +remains. ### Shipped release contract and remaining implementation diff --git a/docs/security-model.md b/docs/security-model.md index 2aa8f54..1f9e7c1 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -122,17 +122,20 @@ readable, and dangerous to let others edit. network-disabled helper with a read-only container root; no archive member is turned into a Windows host path. - **Fail-closed self-update selection.** `cb self-update --check` accepts only a - release-qualified Windows/amd64 build, queries the canonical GitHub repository + release-qualified Windows/amd64 or Windows/arm64 build selected from native + `GOARCH`, queries the canonical GitHub repository over HTTPS with a bounded response, and requires exact canonical release and asset URLs, names and sizes. Downgrades and prereleases require explicit flags. The command performs no asset download and changes no installed files. A separate, not-yet-exposed staging phase downloads exact advertised bytes - for `cb.exe` and `SHA256SUMS` beside a supplied, existing installed + for the selected artifact and `SHA256SUMS` beside a supplied, existing installed executable, accepting only the canonical URL or one HTTPS redirect to GitHub's release-asset host. Staging applies a protected current-user-only DACL on Windows and removes partial staging on any failure. Later phases must require both checksums and GitHub provenance without a fallback before - replacement is possible. + replacement is possible. On ARM64, archive checksum and provenance are + verified before an exact three-file archive layout is parsed and `cb.exe` is + extracted; unexpected, duplicate or unsafe entries fail closed. ## What ContainerBin does NOT protect against diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index d5973ff..761fe6e 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -54,8 +54,16 @@ type Plan struct { ExpectedRef string Workflow string downgradeAuthorization downgradeAuthorization + checksumLayout checksumLayout } +type checksumLayout uint8 + +const ( + checksumLayoutLegacyAMD64 checksumLayout = iota + 1 + checksumLayoutDualArch +) + type downgradeAuthorization struct { current string target string @@ -150,8 +158,8 @@ func (c checker) Plan(ctx context.Context, current, goos, goarch string, opts Op if err != nil { return Plan{}, err } - if goos != "windows" || goarch != "amd64" { - return Plan{}, fmt.Errorf("self-update has no qualified artifact for %s/%s (supported: windows/amd64)", goos, goarch) + if goos != "windows" || (goarch != "amd64" && goarch != "arm64") { + return Plan{}, fmt.Errorf("self-update has no qualified artifact for %s/%s (supported: windows/amd64, windows/arm64)", goos, goarch) } selected, channel, err := c.selectRelease(ctx, current, opts) if err != nil { @@ -171,24 +179,25 @@ func (c checker) Plan(ctx context.Context, current, goos, goarch string, opts Op case comparison > 0: status = "DOWNGRADE AUTHORIZED (CHECK ONLY)" } - binary, archive, checksums, err := validateRelease(selected, goos, goarch) + binary, archive, checksums, layout, err := validateRelease(selected, goarch) if err != nil { return Plan{}, err } plan := Plan{ - Current: currentParsed.raw, - Target: target.raw, - Channel: channel, - Status: status, - OS: goos, - Arch: goarch, - ReleaseURL: selected.HTMLURL, - Binary: binary, - Archive: archive, - Checksums: checksums, - ExpectedRepo: "AviBackToBlack/container-bin", - ExpectedRef: "refs/tags/" + target.raw, - Workflow: ".github/workflows/release.yml", + Current: currentParsed.raw, + Target: target.raw, + Channel: channel, + Status: status, + OS: goos, + Arch: goarch, + ReleaseURL: selected.HTMLURL, + Binary: binary, + Archive: archive, + Checksums: checksums, + ExpectedRepo: "AviBackToBlack/container-bin", + ExpectedRef: "refs/tags/" + target.raw, + Workflow: ".github/workflows/release.yml", + checksumLayout: layout, } if comparison > 0 && opts.AllowDowngrade { plan.downgradeAuthorization = downgradeAuthorization{current: currentParsed.raw, target: target.raw} @@ -277,17 +286,23 @@ func (c checker) getJSON(ctx context.Context, endpoint, current string, dst any) return nil } -func validateRelease(selected release, goos, goarch string) (Asset, Asset, Asset, error) { +func validateRelease(selected release, goarch string) (Asset, Asset, Asset, checksumLayout, error) { tag, err := parseVersion(selected.TagName) if err != nil { - return Asset{}, Asset{}, Asset{}, err + return Asset{}, Asset{}, Asset{}, 0, err } wantReleaseURL := releaseWebRoot + "/tag/" + tag.raw if selected.HTMLURL != wantReleaseURL { - return Asset{}, Asset{}, Asset{}, fmt.Errorf("release URL %q is outside the canonical release page", selected.HTMLURL) + return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release URL %q is outside the canonical release page", selected.HTMLURL) + } + amd64Archive := fmt.Sprintf("container-bin-%s-windows-amd64.zip", tag.raw) + arm64Archive := fmt.Sprintf("container-bin-%s-windows-arm64.zip", tag.raw) + wanted := map[string]int64{ + "cb.exe": maxBinarySize, + amd64Archive: maxArchiveSize, + arm64Archive: maxArchiveSize, + "SHA256SUMS": maxChecksumSize, } - archiveName := fmt.Sprintf("container-bin-%s-%s-%s.zip", tag.raw, goos, goarch) - wanted := map[string]int64{"cb.exe": maxBinarySize, archiveName: maxArchiveSize, "SHA256SUMS": maxChecksumSize} found := map[string]Asset{} for _, candidate := range selected.Assets { limit, required := wanted[candidate.Name] @@ -295,23 +310,33 @@ func validateRelease(selected release, goos, goarch string) (Asset, Asset, Asset continue } if _, duplicate := found[candidate.Name]; duplicate { - return Asset{}, Asset{}, Asset{}, fmt.Errorf("release contains duplicate required asset %q", candidate.Name) + return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release contains duplicate required asset %q", candidate.Name) } if candidate.Size <= 0 || candidate.Size > limit { - return Asset{}, Asset{}, Asset{}, fmt.Errorf("release asset %q has invalid size %d (limit %d)", candidate.Name, candidate.Size, limit) + return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release asset %q has invalid size %d (limit %d)", candidate.Name, candidate.Size, limit) } wantURL := releaseWebRoot + "/download/" + tag.raw + "/" + candidate.Name if candidate.BrowserDownloadURL != wantURL { - return Asset{}, Asset{}, Asset{}, fmt.Errorf("release asset %q has non-canonical download URL", candidate.Name) + return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release asset %q has non-canonical download URL", candidate.Name) } found[candidate.Name] = Asset{Name: candidate.Name, URL: candidate.BrowserDownloadURL, Size: candidate.Size} } - for name := range wanted { + for _, name := range []string{"cb.exe", amd64Archive, "SHA256SUMS"} { if _, ok := found[name]; !ok { - return Asset{}, Asset{}, Asset{}, fmt.Errorf("release is missing required asset %q", name) + return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release is missing required asset %q", name) } } - return found["cb.exe"], found[archiveName], found["SHA256SUMS"], nil + layout := checksumLayoutLegacyAMD64 + if _, ok := found[arm64Archive]; ok { + layout = checksumLayoutDualArch + } + if goarch == "arm64" && layout != checksumLayoutDualArch { + return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release is missing required asset %q", arm64Archive) + } + if goarch == "arm64" { + return found[arm64Archive], found[arm64Archive], found["SHA256SUMS"], layout, nil + } + return found["cb.exe"], found[amd64Archive], found["SHA256SUMS"], layout, nil } func printPlan(out io.Writer, plan Plan) { @@ -322,8 +347,10 @@ func printPlan(out io.Writer, plan Plan) { fmt.Fprintf(out, "status: %s\n", plan.Status) fmt.Fprintf(out, "platform: %s/%s\n", plan.OS, plan.Arch) fmt.Fprintf(out, "release: %s\n", plan.ReleaseURL) - fmt.Fprintf(out, "binary: %s (%d bytes)\n", plan.Binary.Name, plan.Binary.Size) - fmt.Fprintf(out, "archive: %s (%d bytes)\n", plan.Archive.Name, plan.Archive.Size) + fmt.Fprintf(out, "artifact: %s (%d bytes)\n", plan.Binary.Name, plan.Binary.Size) + if plan.Archive.Name != plan.Binary.Name { + fmt.Fprintf(out, "archive: %s (%d bytes)\n", plan.Archive.Name, plan.Archive.Size) + } fmt.Fprintf(out, "checksums: %s (%d bytes)\n", plan.Checksums.Name, plan.Checksums.Size) fmt.Fprintf(out, "verification: gh attestation verify; repository=%s workflow=%s ref=%s; checksums additionally required; no fallback\n", plan.ExpectedRepo, plan.Workflow, plan.ExpectedRef) fmt.Fprintln(out, "apply: unavailable in this slice; verified download and transactional replacement are separate roadmap phases") diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index dd2fdd9..6f1b965 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -109,6 +109,26 @@ func TestPlanStableSelectionAndHeaders(t *testing.T) { } } +func TestPlanSelectsARM64ArchiveFromGOARCH(t *testing.T) { + selected := canonicalDualArchRelease("v1.2.0", false) + c := checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)} + plan, err := c.Plan(context.Background(), "v1.1.0", "windows", "arm64", Options{Check: true}) + if err != nil { + t.Fatal(err) + } + want := "container-bin-v1.2.0-windows-arm64.zip" + if plan.Binary.Name != want || plan.Archive.Name != want || plan.checksumLayout != checksumLayoutDualArch { + t.Fatalf("unexpected ARM64 plan: %+v", plan) + } + amd64, err := (checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)}).Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true}) + if err != nil { + t.Fatal(err) + } + if amd64.Binary.Name != "cb.exe" || amd64.checksumLayout != checksumLayoutDualArch { + t.Fatalf("dual-architecture release changed amd64 selection: %+v", amd64) + } +} + func TestPlanExactAndDowngradePolicy(t *testing.T) { selected := canonicalRelease("v1.0.0", false) c := checker{doer: releaseDoer(t, apiRoot+"/releases/tags/v1.0.0", selected)} @@ -164,7 +184,7 @@ func TestPlanRejectsDevelopmentAndUnsupportedPlatformBeforeNetwork(t *testing.T) }{ {"dev", "windows", "amd64", "development builds"}, {"v0.0.0-dev.abc", "windows", "amd64", "development builds"}, - {"v1.1.0", "windows", "arm64", "no qualified artifact"}, + {"v1.1.0", "windows", "386", "no qualified artifact"}, {"v1.1.0", "linux", "amd64", "no qualified artifact"}, } { _, err := c.Plan(context.Background(), tc.current, tc.goos, tc.goarch, Options{Check: true}) @@ -205,6 +225,15 @@ func TestPlanRejectsUnsafeReleaseMetadata(t *testing.T) { } } +func TestPlanRejectsUnsafeCompanionARM64MetadataOnAMD64(t *testing.T) { + selected := canonicalDualArchRelease("v1.2.0", false) + selected.Assets[len(selected.Assets)-1].BrowserDownloadURL = "https://evil.example/arm64.zip" + _, err := (checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)}).Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true}) + if err == nil || !strings.Contains(err.Error(), "non-canonical download URL") { + t.Fatalf("unsafe companion ARM64 metadata error = %v", err) + } +} + func TestGetJSONBoundsAndTransportFailures(t *testing.T) { for _, tc := range []struct { name string @@ -261,6 +290,17 @@ func canonicalRelease(tag string, prerelease bool) release { } } +func canonicalDualArchRelease(tag string, prerelease bool) release { + release := canonicalRelease(tag, prerelease) + name := "container-bin-" + tag + "-windows-arm64.zip" + release.Assets = append(release.Assets, releaseAsset{ + Name: name, + Size: 2 << 20, + BrowserDownloadURL: releaseWebRoot + "/download/" + tag + "/" + name, + }) + return release +} + func releaseDoer(t *testing.T, wantURL string, value any) httpDoer { t.Helper() return doerFunc(func(req *http.Request) (*http.Response, error) { diff --git a/internal/selfupdate/stage.go b/internal/selfupdate/stage.go index 034f929..cba5ded 100644 --- a/internal/selfupdate/stage.go +++ b/internal/selfupdate/stage.go @@ -36,8 +36,10 @@ func (s Staged) Cleanup() error { return nil } dir := filepath.Clean(s.Dir) + binaryName := filepath.Base(filepath.Clean(s.BinaryPath)) + validBinaryName := binaryName == "cb.exe" || binaryName == fmt.Sprintf("container-bin-%s-windows-arm64.zip", s.Target) if !s.owned || !filepath.IsAbs(dir) || !strings.HasPrefix(filepath.Base(dir), stagingPrefix) || - filepath.Clean(s.BinaryPath) != filepath.Join(dir, "cb.exe") || + !validBinaryName || filepath.Clean(s.BinaryPath) != filepath.Join(dir, binaryName) || filepath.Clean(s.ChecksumsPath) != filepath.Join(dir, "SHA256SUMS") { return errors.New("refusing to remove an invalid self-update staging layout") } @@ -53,10 +55,11 @@ type stager struct { removeAll func(string) error } -// Stage downloads the directly attested executable and its checksum manifest +// Stage downloads the selected attested artifact and its checksum manifest // into a private temporary directory beside the installed management -// executable. The caller must still authenticate and verify both inputs before -// any replacement. +// executable. The artifact is cb.exe on amd64 and the architecture-specific +// archive on arm64. The caller must authenticate and verify both inputs before +// any extraction or replacement. func Stage(ctx context.Context, plan Plan, installedExecutable string) (Staged, error) { return (stager{doer: newDownloadClient()}).Stage(ctx, plan, installedExecutable) } @@ -215,7 +218,7 @@ func validateStagingPlan(plan Plan) error { case comparison < 0 && plan.downgradeAuthorization != (downgradeAuthorization{}): return errors.New("self-update staging plan has inconsistent downgrade authorization") } - if plan.OS != "windows" || plan.Arch != "amd64" { + if plan.OS != "windows" || (plan.Arch != "amd64" && plan.Arch != "arm64") { return fmt.Errorf("self-update staging has no qualified artifact for %s/%s", plan.OS, plan.Arch) } if plan.ReleaseURL != releaseWebRoot+"/tag/"+target.raw { @@ -224,13 +227,29 @@ func validateStagingPlan(plan Plan) error { if plan.ExpectedRepo != "AviBackToBlack/container-bin" || plan.ExpectedRef != "refs/tags/"+target.raw || plan.Workflow != ".github/workflows/release.yml" { return errors.New("self-update staging plan has an unexpected provenance policy") } - archiveName := fmt.Sprintf("container-bin-%s-windows-amd64.zip", target.raw) + amd64Archive := fmt.Sprintf("container-bin-%s-windows-amd64.zip", target.raw) + arm64Archive := fmt.Sprintf("container-bin-%s-windows-arm64.zip", target.raw) + binaryName := "cb.exe" + binaryLimit := int64(maxBinarySize) + archiveName := amd64Archive + wantLayout := checksumLayoutLegacyAMD64 + if plan.Arch == "arm64" { + binaryName = arm64Archive + binaryLimit = maxArchiveSize + archiveName = arm64Archive + wantLayout = checksumLayoutDualArch + } else if plan.checksumLayout == checksumLayoutDualArch { + wantLayout = checksumLayoutDualArch + } + if plan.checksumLayout != wantLayout { + return errors.New("self-update staging plan has an unexpected checksum layout") + } for _, expected := range []struct { asset Asset name string limit int64 }{ - {plan.Binary, "cb.exe", maxBinarySize}, + {plan.Binary, binaryName, binaryLimit}, {plan.Archive, archiveName, maxArchiveSize}, {plan.Checksums, "SHA256SUMS", maxChecksumSize}, } { diff --git a/internal/selfupdate/stage_test.go b/internal/selfupdate/stage_test.go index 02c2f4b..37f04c9 100644 --- a/internal/selfupdate/stage_test.go +++ b/internal/selfupdate/stage_test.go @@ -85,6 +85,35 @@ func TestStagedCleanupRejectsUnrelatedPaths(t *testing.T) { } } +func TestStageDownloadsARM64ArchiveSelectedByPlan(t *testing.T) { + plan := arm64StagingPlan() + archive := bytes.Repeat([]byte("a"), int(plan.Binary.Size)) + checksums := bytes.Repeat([]byte("s"), int(plan.Checksums.Size)) + doer := doerFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.String() { + case plan.Checksums.URL: + return assetResponse(req, checksums), nil + case plan.Binary.URL: + return assetResponse(req, archive), nil + default: + t.Fatalf("unexpected download URL: %s", req.URL) + return nil, nil + } + }) + _, installed := testInstallation(t) + staged, err := (stager{doer: doer}).Stage(context.Background(), plan, installed) + if err != nil { + t.Fatal(err) + } + if filepath.Base(staged.BinaryPath) != plan.Binary.Name { + t.Fatalf("staged artifact = %q, want %q", staged.BinaryPath, plan.Binary.Name) + } + assertFile(t, staged.BinaryPath, archive) + if err := staged.Cleanup(); err != nil { + t.Fatal(err) + } +} + func TestStageCleansUpEveryPartialFailure(t *testing.T) { cases := []struct { name string @@ -204,7 +233,7 @@ func TestStageRejectsInvalidInputsBeforeCreatingFilesOrCallingNetwork(t *testing {name: "inconsistent downgrade authorization", mutate: func(p *Plan) { p.downgradeAuthorization = downgradeAuthorization{current: p.Current, target: "v1.0.0"} }, want: "inconsistent"}, - {name: "wrong platform", mutate: func(p *Plan) { p.Arch = "arm64" }, want: "no qualified artifact"}, + {name: "wrong platform", mutate: func(p *Plan) { p.Arch = "386" }, want: "no qualified artifact"}, {name: "wrong release", mutate: func(p *Plan) { p.ReleaseURL = "https://evil.example/release" }, want: "non-canonical release URL"}, {name: "wrong provenance", mutate: func(p *Plan) { p.ExpectedRepo = "other/repo" }, want: "unexpected provenance policy"}, {name: "wrong binary name", mutate: func(p *Plan) { p.Binary.Name = "other.exe" }, want: "expected asset"}, @@ -329,22 +358,34 @@ func TestDownloadAcceptsOnlyCanonicalOrSingleGitHubAssetRedirect(t *testing.T) { func stagingPlan() Plan { return Plan{ - Current: "v1.1.0", - Target: "v1.2.0", - Channel: "stable", - Status: "UPDATE AVAILABLE", - OS: "windows", - Arch: "amd64", - ReleaseURL: releaseWebRoot + "/tag/v1.2.0", - Binary: Asset{Name: "cb.exe", URL: releaseWebRoot + "/download/v1.2.0/cb.exe", Size: 16}, - Archive: Asset{Name: "container-bin-v1.2.0-windows-amd64.zip", URL: releaseWebRoot + "/download/v1.2.0/container-bin-v1.2.0-windows-amd64.zip", Size: 32}, - Checksums: Asset{Name: "SHA256SUMS", URL: releaseWebRoot + "/download/v1.2.0/SHA256SUMS", Size: 64}, - ExpectedRepo: "AviBackToBlack/container-bin", - ExpectedRef: "refs/tags/v1.2.0", - Workflow: ".github/workflows/release.yml", + Current: "v1.1.0", + Target: "v1.2.0", + Channel: "stable", + Status: "UPDATE AVAILABLE", + OS: "windows", + Arch: "amd64", + ReleaseURL: releaseWebRoot + "/tag/v1.2.0", + Binary: Asset{Name: "cb.exe", URL: releaseWebRoot + "/download/v1.2.0/cb.exe", Size: 16}, + Archive: Asset{Name: "container-bin-v1.2.0-windows-amd64.zip", URL: releaseWebRoot + "/download/v1.2.0/container-bin-v1.2.0-windows-amd64.zip", Size: 32}, + Checksums: Asset{Name: "SHA256SUMS", URL: releaseWebRoot + "/download/v1.2.0/SHA256SUMS", Size: 64}, + ExpectedRepo: "AviBackToBlack/container-bin", + ExpectedRef: "refs/tags/v1.2.0", + Workflow: ".github/workflows/release.yml", + checksumLayout: checksumLayoutLegacyAMD64, } } +func arm64StagingPlan() Plan { + plan := stagingPlan() + plan.Arch = "arm64" + name := "container-bin-v1.2.0-windows-arm64.zip" + asset := Asset{Name: name, URL: releaseWebRoot + "/download/v1.2.0/" + name, Size: 32} + plan.Binary = asset + plan.Archive = asset + plan.checksumLayout = checksumLayoutDualArch + return plan +} + func retargetStagingPlan(plan Plan, target string) Plan { plan.Target = target plan.ReleaseURL = releaseWebRoot + "/tag/" + target diff --git a/internal/selfupdate/verify.go b/internal/selfupdate/verify.go index e16b3b0..b2aff04 100644 --- a/internal/selfupdate/verify.go +++ b/internal/selfupdate/verify.go @@ -1,6 +1,7 @@ package selfupdate import ( + "archive/zip" "bytes" "context" "crypto/sha256" @@ -24,10 +25,10 @@ const ( expectedReleaseWorkflow = ".github/workflows/release.yml" ) -// Verified is an opaque result binding a successful checksum and provenance -// verification to the exact staged executable digest. A later replacement -// phase must re-hash the file and match this result before changing installed -// bytes. +// Verified is an opaque result binding successful checksum and provenance +// verification of the selected release artifact to the exact staged cb.exe +// digest. A later replacement phase must re-hash the file and match this result +// before changing installed bytes. type Verified struct { binaryPath string target string @@ -50,10 +51,11 @@ type verifier struct { } // Verify checks the release checksum and GitHub build-provenance attestation -// for a staged cb.exe. ghExecutable must name an explicitly selected absolute, -// regular executable with a valid GitHub, Inc. Authenticode signature. Verify -// never searches PATH, passes inherited verifier configuration, or falls back -// to checksum-only acceptance. +// for the selected staged artifact, then extracts the exact cb.exe only when +// the authenticated ARM64 artifact is an archive. ghExecutable must name an +// explicitly selected absolute, regular executable with a valid GitHub, Inc. +// Authenticode signature. Verify never searches PATH, passes inherited verifier +// configuration, or falls back to checksum-only acceptance. func Verify(ctx context.Context, plan Plan, binaryPath, checksumsPath, ghExecutable string) (Verified, error) { return (verifier{ runner: commandAttestationRunner{}, @@ -72,7 +74,7 @@ func (v verifier) Verify(ctx context.Context, plan Plan, binaryPath, checksumsPa if err != nil { return Verified{}, err } - binaryPath, binaryInfo, err := canonicalVerificationFile(binaryPath, "staged executable") + binaryPath, binaryInfo, err := canonicalVerificationFile(binaryPath, "staged release artifact") if err != nil { return Verified{}, err } @@ -84,7 +86,7 @@ func (v verifier) Verify(ctx context.Context, plan Plan, binaryPath, checksumsPa return Verified{}, errors.New("self-update verification inputs do not have the exact staged layout") } if binaryInfo.Size() != plan.Binary.Size { - return Verified{}, fmt.Errorf("staged executable size is %d, expected %d", binaryInfo.Size(), plan.Binary.Size) + return Verified{}, fmt.Errorf("staged release artifact size is %d, expected %d", binaryInfo.Size(), plan.Binary.Size) } if checksumsInfo.Size() != plan.Checksums.Size { return Verified{}, fmt.Errorf("checksum manifest size is %d, expected %d", checksumsInfo.Size(), plan.Checksums.Size) @@ -151,7 +153,7 @@ func (v verifier) Verify(ctx context.Context, plan Plan, binaryPath, checksumsPa return Verified{}, errors.New("GitHub CLI executable changed during verification") } - postPath, postInfo, err := canonicalVerificationFile(binaryPath, "staged executable") + postPath, postInfo, err := canonicalVerificationFile(binaryPath, "staged release artifact") if err != nil { return Verified{}, err } @@ -160,7 +162,24 @@ func (v verifier) Verify(ctx context.Context, plan Plan, binaryPath, checksumsPa return Verified{}, err } if postPath != binaryPath || postSize != size || postDigest != digest { - return Verified{}, errors.New("staged executable changed during verification") + return Verified{}, errors.New("staged release artifact changed during verification") + } + if plan.Arch == "arm64" { + extractedPath, extractedDigest, extractedSize, err := extractVerifiedExecutable(binaryPath, postInfo) + if err != nil { + return Verified{}, err + } + finalPath, finalInfo, err := canonicalVerificationFile(binaryPath, "verified ARM64 archive") + if err != nil { + _ = os.Remove(extractedPath) + return Verified{}, err + } + finalDigest, finalSize, err := hashVerificationFile(finalPath, finalInfo) + if err != nil || finalPath != binaryPath || finalSize != size || finalDigest != digest { + _ = os.Remove(extractedPath) + return Verified{}, errors.New("verified ARM64 archive changed during extraction") + } + return Verified{binaryPath: extractedPath, target: target.raw, digest: extractedDigest, size: extractedSize}, nil } return Verified{binaryPath: binaryPath, target: target.raw, digest: digest, size: size}, nil } @@ -173,23 +192,39 @@ func validateVerificationPlan(plan Plan) (semanticVersion, error) { if err != nil { return semanticVersion{}, fmt.Errorf("invalid self-update verification target: %w", err) } - if plan.OS != "windows" || plan.Arch != "amd64" { + if plan.OS != "windows" || (plan.Arch != "amd64" && plan.Arch != "arm64") { return semanticVersion{}, fmt.Errorf("self-update verification has no qualified artifact for %s/%s", plan.OS, plan.Arch) } if plan.ExpectedRepo != expectedReleaseRepo || plan.ExpectedRef != "refs/tags/"+target.raw || plan.Workflow != expectedReleaseWorkflow { return semanticVersion{}, errors.New("self-update verification plan has an unexpected provenance policy") } - archiveName := fmt.Sprintf("container-bin-%s-windows-amd64.zip", target.raw) - if plan.Binary.Name != "cb.exe" || plan.Archive.Name != archiveName || plan.Checksums.Name != "SHA256SUMS" { + amd64Archive := fmt.Sprintf("container-bin-%s-windows-amd64.zip", target.raw) + arm64Archive := fmt.Sprintf("container-bin-%s-windows-arm64.zip", target.raw) + binaryName := "cb.exe" + archiveName := amd64Archive + binaryLimit := int64(maxBinarySize) + wantLayout := checksumLayoutLegacyAMD64 + if plan.Arch == "arm64" { + binaryName = arm64Archive + archiveName = arm64Archive + binaryLimit = maxArchiveSize + wantLayout = checksumLayoutDualArch + } else if plan.checksumLayout == checksumLayoutDualArch { + wantLayout = checksumLayoutDualArch + } + if plan.checksumLayout != wantLayout { + return semanticVersion{}, errors.New("self-update verification plan has an unexpected checksum layout") + } + if plan.Binary.Name != binaryName || plan.Archive.Name != archiveName || plan.Checksums.Name != "SHA256SUMS" { return semanticVersion{}, errors.New("self-update verification plan has an unexpected asset layout") } if plan.ReleaseURL != releaseWebRoot+"/tag/"+target.raw || - plan.Binary.URL != releaseWebRoot+"/download/"+target.raw+"/cb.exe" || + plan.Binary.URL != releaseWebRoot+"/download/"+target.raw+"/"+binaryName || plan.Archive.URL != releaseWebRoot+"/download/"+target.raw+"/"+archiveName || plan.Checksums.URL != releaseWebRoot+"/download/"+target.raw+"/SHA256SUMS" { return semanticVersion{}, errors.New("self-update verification plan has a non-canonical release URL") } - if plan.Binary.Size <= 0 || plan.Binary.Size > maxBinarySize || plan.Archive.Size <= 0 || plan.Archive.Size > maxArchiveSize || plan.Checksums.Size <= 0 || plan.Checksums.Size > maxChecksumSize { + if plan.Binary.Size <= 0 || plan.Binary.Size > binaryLimit || plan.Archive.Size <= 0 || plan.Archive.Size > maxArchiveSize || plan.Checksums.Size <= 0 || plan.Checksums.Size > maxChecksumSize { return semanticVersion{}, errors.New("self-update verification plan has an invalid asset size") } return target, nil @@ -226,7 +261,7 @@ func canonicalVerificationFile(path, label string) (string, os.FileInfo, error) } func hashVerificationFile(path string, expected os.FileInfo) (string, int64, error) { - return hashRegularFile(path, expected, "staged executable") + return hashRegularFile(path, expected, "verification file") } func hashRegularFile(path string, expected os.FileInfo, label string) (string, int64, error) { @@ -280,7 +315,14 @@ func verifyChecksumManifest(data []byte, plan Plan, binaryDigest string) error { if len(data) == 0 || data[len(data)-1] != '\n' || bytes.Contains(data, []byte{'\r'}) { return errors.New("checksum manifest must use canonical LF-terminated lines") } - wantNames := map[string]bool{plan.Binary.Name: false, plan.Archive.Name: false} + wantList, err := checksumManifestNames(plan) + if err != nil { + return err + } + wantNames := make(map[string]bool, len(wantList)) + for _, name := range wantList { + wantNames[name] = false + } for _, line := range strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") { if len(line) < sha256.Size*2+2 || line[sha256.Size*2:sha256.Size*2+2] != " " { return errors.New("checksum manifest has a non-canonical entry") @@ -300,10 +342,10 @@ func verifyChecksumManifest(data []byte, plan Plan, binaryDigest string) error { } wantNames[name] = true if name == plan.Binary.Name && digest != binaryDigest { - return errors.New("staged executable checksum does not match SHA256SUMS") + return errors.New("staged release artifact checksum does not match SHA256SUMS") } } - for _, name := range []string{plan.Binary.Name, plan.Archive.Name} { + for _, name := range wantList { if !wantNames[name] { return fmt.Errorf("checksum manifest is missing asset %q", name) } @@ -311,6 +353,106 @@ func verifyChecksumManifest(data []byte, plan Plan, binaryDigest string) error { return nil } +func checksumManifestNames(plan Plan) ([]string, error) { + target, err := parseVersion(plan.Target) + if err != nil { + return nil, fmt.Errorf("checksum manifest plan target: %w", err) + } + amd64Archive := fmt.Sprintf("container-bin-%s-windows-amd64.zip", target.raw) + switch plan.checksumLayout { + case checksumLayoutLegacyAMD64: + return []string{"cb.exe", amd64Archive}, nil + case checksumLayoutDualArch: + return []string{"cb.exe", amd64Archive, fmt.Sprintf("container-bin-%s-windows-arm64.zip", target.raw)}, nil + default: + return nil, errors.New("checksum manifest plan has an unexpected layout") + } +} + +func extractVerifiedExecutable(archivePath string, expected os.FileInfo) (path, digest string, size int64, err error) { + archive, err := os.Open(archivePath) + if err != nil { + return "", "", 0, fmt.Errorf("open verified ARM64 archive: %w", err) + } + defer archive.Close() + opened, err := archive.Stat() + if err != nil { + return "", "", 0, fmt.Errorf("inspect verified ARM64 archive: %w", err) + } + if !os.SameFile(expected, opened) || !opened.Mode().IsRegular() || opened.Size() <= 0 || opened.Size() > maxArchiveSize { + return "", "", 0, errors.New("verified ARM64 archive changed before extraction") + } + reader, err := zip.NewReader(archive, opened.Size()) + if err != nil { + return "", "", 0, fmt.Errorf("open verified ARM64 archive layout: %w", err) + } + wanted := map[string]*zip.File{"cb.exe": nil, "LICENSE": nil, "README.md": nil} + var total uint64 + for _, entry := range reader.File { + if _, ok := wanted[entry.Name]; !ok { + return "", "", 0, fmt.Errorf("verified ARM64 archive contains unexpected entry %q", entry.Name) + } + if wanted[entry.Name] != nil { + return "", "", 0, fmt.Errorf("verified ARM64 archive contains duplicate entry %q", entry.Name) + } + if entry.Flags&0x1 != 0 || (entry.Method != zip.Store && entry.Method != zip.Deflate) || !entry.FileInfo().Mode().IsRegular() { + return "", "", 0, fmt.Errorf("verified ARM64 archive entry %q has an unsafe type or encoding", entry.Name) + } + if entry.UncompressedSize64 > uint64(maxArchiveSize) || total > uint64(maxArchiveSize)-entry.UncompressedSize64 { + return "", "", 0, errors.New("verified ARM64 archive expands beyond the safety limit") + } + total += entry.UncompressedSize64 + wanted[entry.Name] = entry + } + for name, entry := range wanted { + if entry == nil { + return "", "", 0, fmt.Errorf("verified ARM64 archive is missing entry %q", name) + } + } + binary := wanted["cb.exe"] + if binary.UncompressedSize64 == 0 || binary.UncompressedSize64 > uint64(maxBinarySize) { + return "", "", 0, errors.New("verified ARM64 archive cb.exe size is outside the safety limit") + } + destination := filepath.Join(filepath.Dir(archivePath), "cb.exe") + input, err := binary.Open() + if err != nil { + return "", "", 0, fmt.Errorf("open verified ARM64 archive cb.exe: %w", err) + } + defer input.Close() + output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return "", "", 0, fmt.Errorf("create extracted ARM64 cb.exe: %w", err) + } + keep := false + defer func() { + if !keep { + _ = os.Remove(destination) + } + }() + n, copyErr := io.Copy(output, io.LimitReader(input, maxBinarySize+1)) + syncErr := output.Sync() + closeErr := output.Close() + if err := errors.Join(copyErr, syncErr, closeErr); err != nil { + return "", "", 0, fmt.Errorf("extract verified ARM64 cb.exe: %w", err) + } + if n != int64(binary.UncompressedSize64) { + return "", "", 0, errors.New("verified ARM64 archive cb.exe changed size during extraction") + } + if err := restrictStagingPath(destination, false); err != nil { + return "", "", 0, fmt.Errorf("restrict extracted ARM64 cb.exe: %w", err) + } + clean, info, err := canonicalVerificationFile(destination, "extracted ARM64 executable") + if err != nil { + return "", "", 0, err + } + digest, size, err = hashVerificationFile(clean, info) + if err != nil { + return "", "", 0, err + } + keep = true + return clean, digest, size, nil +} + type attestationOutput []struct { VerificationResult struct { Statement struct { diff --git a/internal/selfupdate/verify_integration_windows_test.go b/internal/selfupdate/verify_integration_windows_test.go index 1819edb..950060d 100644 --- a/internal/selfupdate/verify_integration_windows_test.go +++ b/internal/selfupdate/verify_integration_windows_test.go @@ -24,17 +24,18 @@ func TestVerifyRealGitHubCLIRelease(t *testing.T) { t.Fatal(err) } plan := Plan{ - Current: "v1.0.0", - Target: "v1.1.0", - OS: "windows", - Arch: "amd64", - ReleaseURL: releaseWebRoot + "/tag/v1.1.0", - Binary: Asset{Name: "cb.exe", URL: releaseWebRoot + "/download/v1.1.0/cb.exe", Size: binaryInfo.Size()}, - Archive: Asset{Name: "container-bin-v1.1.0-windows-amd64.zip", URL: releaseWebRoot + "/download/v1.1.0/container-bin-v1.1.0-windows-amd64.zip", Size: 1}, - Checksums: Asset{Name: "SHA256SUMS", URL: releaseWebRoot + "/download/v1.1.0/SHA256SUMS", Size: checksumsInfo.Size()}, - ExpectedRepo: expectedReleaseRepo, - ExpectedRef: "refs/tags/v1.1.0", - Workflow: expectedReleaseWorkflow, + Current: "v1.0.0", + Target: "v1.1.0", + OS: "windows", + Arch: "amd64", + ReleaseURL: releaseWebRoot + "/tag/v1.1.0", + Binary: Asset{Name: "cb.exe", URL: releaseWebRoot + "/download/v1.1.0/cb.exe", Size: binaryInfo.Size()}, + Archive: Asset{Name: "container-bin-v1.1.0-windows-amd64.zip", URL: releaseWebRoot + "/download/v1.1.0/container-bin-v1.1.0-windows-amd64.zip", Size: 1}, + Checksums: Asset{Name: "SHA256SUMS", URL: releaseWebRoot + "/download/v1.1.0/SHA256SUMS", Size: checksumsInfo.Size()}, + ExpectedRepo: expectedReleaseRepo, + ExpectedRef: "refs/tags/v1.1.0", + Workflow: expectedReleaseWorkflow, + checksumLayout: checksumLayoutLegacyAMD64, } verified, err := Verify(context.Background(), plan, binary, checksums, gh) if err != nil { diff --git a/internal/selfupdate/verify_test.go b/internal/selfupdate/verify_test.go index 5308f87..3b50fbf 100644 --- a/internal/selfupdate/verify_test.go +++ b/internal/selfupdate/verify_test.go @@ -1,6 +1,8 @@ package selfupdate import ( + "archive/zip" + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -108,7 +110,7 @@ func TestVerifyRejectsInvalidPolicyLayoutAndPathsBeforeAttestation(t *testing.T) {name: "wrong repository", mutate: func(f *verificationFixture) { f.plan.ExpectedRepo = "other/repo" }, want: "unexpected provenance policy"}, {name: "wrong workflow", mutate: func(f *verificationFixture) { f.plan.Workflow = ".github/workflows/other.yml" }, want: "unexpected provenance policy"}, {name: "wrong ref", mutate: func(f *verificationFixture) { f.plan.ExpectedRef = "refs/heads/main" }, want: "unexpected provenance policy"}, - {name: "wrong architecture", mutate: func(f *verificationFixture) { f.plan.Arch = "arm64" }, want: "no qualified artifact"}, + {name: "wrong architecture", mutate: func(f *verificationFixture) { f.plan.Arch = "386" }, want: "no qualified artifact"}, {name: "wrong binary name", mutate: func(f *verificationFixture) { f.plan.Binary.Name = "other.exe" }, want: "unexpected asset layout"}, {name: "wrong archive name", mutate: func(f *verificationFixture) { f.plan.Archive.Name = "other.zip" }, want: "unexpected asset layout"}, {name: "wrong release URL", mutate: func(f *verificationFixture) { f.plan.ReleaseURL = "https://evil.example/release" }, want: "non-canonical release URL"}, @@ -203,6 +205,67 @@ func TestVerifyDetectsExecutableMutationDuringAttestation(t *testing.T) { } } +func TestVerifyARM64AuthenticatesArchiveBeforeExactExtraction(t *testing.T) { + fixture := newARM64VerificationFixture(t, nil) + called := false + verified, err := testVerifier(attestationRunnerFunc(func(_ context.Context, _ string, args []string) ([]byte, []byte, error) { + called = true + if args[2] != mustResolveTestPath(t, fixture.binary) { + t.Fatalf("attested path = %q", args[2]) + } + return attestationJSON(fixture.digest), nil, nil + })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.gh) + if err != nil { + t.Fatal(err) + } + if !called || filepath.Base(verified.BinaryPath()) != "cb.exe" { + t.Fatalf("unexpected ARM64 verification result: called=%t verified=%+v", called, verified) + } + data, err := os.ReadFile(verified.BinaryPath()) + if err != nil { + t.Fatal(err) + } + want := []byte("verified ARM64 ContainerBin executable") + sum := sha256.Sum256(want) + if !bytes.Equal(data, want) || verified.SHA256() != hex.EncodeToString(sum[:]) || verified.Size() != int64(len(want)) { + t.Fatalf("unexpected extracted ARM64 executable: bytes=%q verified=%+v", data, verified) + } +} + +func TestVerifyAMD64AcceptsCanonicalDualArchitectureManifest(t *testing.T) { + fixture := newVerificationFixture(t) + manifest, err := os.ReadFile(fixture.checksums) + if err != nil { + t.Fatal(err) + } + manifest = append(manifest, []byte(strings.Repeat("b", 64)+" container-bin-v1.2.0-windows-arm64.zip\n")...) + if err := os.WriteFile(fixture.checksums, manifest, 0o600); err != nil { + t.Fatal(err) + } + fixture.plan.Checksums.Size = int64(len(manifest)) + fixture.plan.checksumLayout = checksumLayoutDualArch + if _, err := testVerifier(attestationRunnerFunc(func(context.Context, string, []string) ([]byte, []byte, error) { + return attestationJSON(fixture.digest), nil, nil + })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.gh); err != nil { + t.Fatal(err) + } +} + +func TestVerifyARM64RejectsUnexpectedArchiveEntryAfterAuthentication(t *testing.T) { + fixture := newARM64VerificationFixture(t, map[string][]byte{"../escape": []byte("unsafe")}) + called := false + _, err := testVerifier(attestationRunnerFunc(func(context.Context, string, []string) ([]byte, []byte, error) { + called = true + return attestationJSON(fixture.digest), nil, nil + })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.gh) + if err == nil || !strings.Contains(err.Error(), "unexpected entry") || !called { + t.Fatalf("ARM64 unsafe archive Verify = %v, called=%t", err, called) + } + if _, statErr := os.Stat(filepath.Join(filepath.Dir(fixture.binary), "cb.exe")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("unsafe archive left extracted executable: %v", statErr) + } +} + func TestVerifyRejectsGitHubCLIAuthenticationFailureOrMutation(t *testing.T) { t.Run("authentication failure", func(t *testing.T) { fixture := newVerificationFixture(t) @@ -386,17 +449,18 @@ func newVerificationFixture(t *testing.T) verificationFixture { } return verificationFixture{ plan: Plan{ - Current: "v1.1.0", - Target: "v1.2.0", - OS: "windows", - Arch: "amd64", - ReleaseURL: releaseWebRoot + "/tag/v1.2.0", - Binary: Asset{Name: "cb.exe", URL: releaseWebRoot + "/download/v1.2.0/cb.exe", Size: int64(len(binaryBytes))}, - Archive: Asset{Name: archiveName, URL: releaseWebRoot + "/download/v1.2.0/" + archiveName, Size: 1}, - Checksums: Asset{Name: "SHA256SUMS", URL: releaseWebRoot + "/download/v1.2.0/SHA256SUMS", Size: int64(len(manifest))}, - ExpectedRepo: expectedReleaseRepo, - ExpectedRef: "refs/tags/v1.2.0", - Workflow: expectedReleaseWorkflow, + Current: "v1.1.0", + Target: "v1.2.0", + OS: "windows", + Arch: "amd64", + ReleaseURL: releaseWebRoot + "/tag/v1.2.0", + Binary: Asset{Name: "cb.exe", URL: releaseWebRoot + "/download/v1.2.0/cb.exe", Size: int64(len(binaryBytes))}, + Archive: Asset{Name: archiveName, URL: releaseWebRoot + "/download/v1.2.0/" + archiveName, Size: 1}, + Checksums: Asset{Name: "SHA256SUMS", URL: releaseWebRoot + "/download/v1.2.0/SHA256SUMS", Size: int64(len(manifest))}, + ExpectedRepo: expectedReleaseRepo, + ExpectedRef: "refs/tags/v1.2.0", + Workflow: expectedReleaseWorkflow, + checksumLayout: checksumLayoutLegacyAMD64, }, binary: binary, checksums: checksums, @@ -406,6 +470,83 @@ func newVerificationFixture(t *testing.T) verificationFixture { } } +func newARM64VerificationFixture(t *testing.T, extras map[string][]byte) verificationFixture { + t.Helper() + dir := t.TempDir() + archiveName := "container-bin-v1.2.0-windows-arm64.zip" + archiveBytes := arm64TestArchive(t, extras) + sum := sha256.Sum256(archiveBytes) + digest := hex.EncodeToString(sum[:]) + manifest := strings.Repeat("a", 64) + " cb.exe\n" + + strings.Repeat("b", 64) + " container-bin-v1.2.0-windows-amd64.zip\n" + + digest + " " + archiveName + "\n" + archive := filepath.Join(dir, archiveName) + checksums := filepath.Join(dir, "SHA256SUMS") + gh := filepath.Join(t.TempDir(), "gh.exe") + for path, data := range map[string][]byte{ + archive: archiveBytes, + checksums: []byte(manifest), + gh: []byte("trusted GitHub CLI test executable"), + } { + if err := os.WriteFile(path, data, 0o700); err != nil { + t.Fatal(err) + } + } + asset := Asset{Name: archiveName, URL: releaseWebRoot + "/download/v1.2.0/" + archiveName, Size: int64(len(archiveBytes))} + return verificationFixture{ + plan: Plan{ + Current: "v1.1.0", + Target: "v1.2.0", + OS: "windows", + Arch: "arm64", + ReleaseURL: releaseWebRoot + "/tag/v1.2.0", + Binary: asset, + Archive: asset, + Checksums: Asset{Name: "SHA256SUMS", URL: releaseWebRoot + "/download/v1.2.0/SHA256SUMS", Size: int64(len(manifest))}, + ExpectedRepo: expectedReleaseRepo, + ExpectedRef: "refs/tags/v1.2.0", + Workflow: expectedReleaseWorkflow, + checksumLayout: checksumLayoutDualArch, + }, + binary: archive, + checksums: checksums, + gh: gh, + binaryBytes: archiveBytes, + digest: digest, + } +} + +func arm64TestArchive(t *testing.T, extras map[string][]byte) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + entries := map[string][]byte{ + "cb.exe": []byte("verified ARM64 ContainerBin executable"), + "LICENSE": []byte("license"), + "README.md": []byte("readme"), + } + for name, data := range extras { + entries[name] = data + } + for _, name := range []string{"cb.exe", "LICENSE", "README.md", "../escape"} { + data, ok := entries[name] + if !ok { + continue + } + entry, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(data); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + func attestationJSON(digest string) []byte { return []byte(fmt.Sprintf(`[{"attestation":{"bundle":"ignored"},"verificationResult":{"statement":{"predicateType":"%s","subject":[{"name":"cb.exe","digest":{"sha256":"%s"}}]}}}]`, provenancePredicate, digest)) } From 72d228f9f2aa9e90ebf7e80a7a42772a0d272395 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:48:42 +0100 Subject: [PATCH 2/3] Make ARM64 verification repeatable --- docs/roadmap-implementation-requirements.md | 14 +++--- internal/selfupdate/verify.go | 51 +++++++++++++++++++++ internal/selfupdate/verify_test.go | 28 +++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index 05f7c2e..92e4e1f 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -72,7 +72,7 @@ The minimum delivery gate for a code change is: | RM-26 Python global CLI exposure | **Completed in PR #74** | Stateful pipx + `cb expose pipx` shipped; plain pip `/venv/bin` remains intentionally unexposed | | RM-29 Windows ARM64 | **Native CI and release packaging shipped / update and hardware work remain** | PR #78 added native hosted ARM64 CI and PR #86 added reproducible release packaging; ARM64 self-update selection and real Windows-on-Arm + Docker Desktop E2E remain | | RM-30 Authenticode | **Design complete / externally blocked** | Provision real code-signing certificate and protected signing mechanism | -| RM-31 self-update | **Selection/check foundation shipped** | PR #76 shipped selection/check behavior; staging, verification, transactional apply and E2E remain | +| RM-31 self-update | **Selection/staging/verifier foundations shipped** | PR #76 shipped selection/check behavior; private staging and attestation verification followed. Transactional helper/apply wiring and E2E remain | | RM-34 Cargo expose enhancement | **Intentionally deferred** | Existing expose-all/explicit selection are sufficient; reopen only for concrete unmet use case | | Linux/macOS hosts | **Demand-gated** | WSL may factor reusable Linux host code; standalone support needs its own demand and qualification | | Enterprise policy | **Foundation shipped / signed registry remains** | PR #75 shipped the machine-owned constraint layer; authenticated registry and image-trust slices remain | @@ -397,10 +397,12 @@ separate phases with explicit boundaries. GitHub documents both and [artifact-attestation verification](https://docs.github.com/en/actions/concepts/security/artifact-attestations). PR #76 shipped the command surface, release selection, check/dry-run behavior, -strict Windows/amd64 asset selection and bounded metadata rules. Unsupported -architectures still fail closed. Download staging, provenance verification, -transactional Windows apply, rollback, broader architecture support and release -E2E remain incomplete until their implementations merge. +strict Windows/amd64 asset selection and bounded metadata rules. Private +same-volume staging and provenance verification followed. The current pipeline +also selects, stages and verifies the Windows/arm64 archive from native +`GOARCH`; unsupported architectures still fail closed. Transactional helper +and user-facing apply wiring plus release E2E remain incomplete until their +implementations merge. ### Command and selection requirements @@ -661,7 +663,7 @@ reproducible ARM64 release packaging in PR #86. 1. Per-project overlay trust foundation. 2. Signed-registry enterprise policy. 3. Image trust at lock time, after signed-registry policy merges. -4. Remaining RM-31 staging, verification, transactional apply and E2E. +4. Remaining RM-31 helper/user-facing transactional apply wiring and E2E. 5. Remaining WSL2 native layout, Docker Desktop integration and real E2E. 6. RM-30 Authenticode only after certificate/protected-signing prerequisites exist. 7. RM-29 ARM64 self-update selection and real Windows-on-Arm + Docker Desktop diff --git a/internal/selfupdate/verify.go b/internal/selfupdate/verify.go index b2aff04..2fe33c8 100644 --- a/internal/selfupdate/verify.go +++ b/internal/selfupdate/verify.go @@ -414,6 +414,37 @@ func extractVerifiedExecutable(archivePath string, expected os.FileInfo) (path, return "", "", 0, errors.New("verified ARM64 archive cb.exe size is outside the safety limit") } destination := filepath.Join(filepath.Dir(archivePath), "cb.exe") + expectedDigest, expectedSize, err := hashArchiveExecutable(binary) + if err != nil { + return "", "", 0, err + } + if _, statErr := os.Lstat(destination); statErr == nil { + clean, info, err := canonicalVerificationFile(destination, "previously extracted ARM64 executable") + if err != nil { + return "", "", 0, err + } + digest, size, err := hashVerificationFile(clean, info) + if err != nil { + return "", "", 0, err + } + if digest != expectedDigest || size != expectedSize { + return "", "", 0, errors.New("existing extracted ARM64 executable does not match the authenticated archive") + } + if err := restrictStagingPath(clean, false); err != nil { + return "", "", 0, fmt.Errorf("restrict existing extracted ARM64 cb.exe: %w", err) + } + postClean, postInfo, err := canonicalVerificationFile(clean, "previously extracted ARM64 executable") + if err != nil { + return "", "", 0, err + } + postDigest, postSize, err := hashVerificationFile(postClean, postInfo) + if err != nil || postClean != clean || postDigest != expectedDigest || postSize != expectedSize { + return "", "", 0, errors.New("existing extracted ARM64 executable changed during re-verification") + } + return clean, digest, size, nil + } else if !errors.Is(statErr, os.ErrNotExist) { + return "", "", 0, fmt.Errorf("inspect extracted ARM64 cb.exe destination: %w", statErr) + } input, err := binary.Open() if err != nil { return "", "", 0, fmt.Errorf("open verified ARM64 archive cb.exe: %w", err) @@ -449,10 +480,30 @@ func extractVerifiedExecutable(archivePath string, expected os.FileInfo) (path, if err != nil { return "", "", 0, err } + if digest != expectedDigest || size != expectedSize { + return "", "", 0, errors.New("extracted ARM64 executable does not match the authenticated archive") + } keep = true return clean, digest, size, nil } +func hashArchiveExecutable(binary *zip.File) (string, int64, error) { + input, err := binary.Open() + if err != nil { + return "", 0, fmt.Errorf("open verified ARM64 archive cb.exe: %w", err) + } + hash := sha256.New() + n, copyErr := io.Copy(hash, io.LimitReader(input, maxBinarySize+1)) + closeErr := input.Close() + if err := errors.Join(copyErr, closeErr); err != nil { + return "", 0, fmt.Errorf("hash verified ARM64 archive cb.exe: %w", err) + } + if n != int64(binary.UncompressedSize64) { + return "", 0, errors.New("verified ARM64 archive cb.exe changed size while hashing") + } + return hex.EncodeToString(hash.Sum(nil)), n, nil +} + type attestationOutput []struct { VerificationResult struct { Statement struct { diff --git a/internal/selfupdate/verify_test.go b/internal/selfupdate/verify_test.go index 3b50fbf..7a3aa00 100644 --- a/internal/selfupdate/verify_test.go +++ b/internal/selfupdate/verify_test.go @@ -230,6 +230,34 @@ func TestVerifyARM64AuthenticatesArchiveBeforeExactExtraction(t *testing.T) { if !bytes.Equal(data, want) || verified.SHA256() != hex.EncodeToString(sum[:]) || verified.Size() != int64(len(want)) { t.Fatalf("unexpected extracted ARM64 executable: bytes=%q verified=%+v", data, verified) } + verifiedAgain, err := testVerifier(attestationRunnerFunc(func(context.Context, string, []string) ([]byte, []byte, error) { + return attestationJSON(fixture.digest), nil, nil + })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.gh) + if err != nil { + t.Fatal(err) + } + if verifiedAgain.BinaryPath() != verified.BinaryPath() || verifiedAgain.SHA256() != verified.SHA256() || verifiedAgain.Size() != verified.Size() { + t.Fatalf("repeat verification changed result: first=%+v second=%+v", verified, verifiedAgain) + } +} + +func TestVerifyARM64RejectsMismatchedExistingExtractionWithoutOverwrite(t *testing.T) { + fixture := newARM64VerificationFixture(t, nil) + destination := filepath.Join(filepath.Dir(fixture.binary), "cb.exe") + want := []byte("untrusted pre-existing bytes") + if err := os.WriteFile(destination, want, 0o600); err != nil { + t.Fatal(err) + } + _, err := testVerifier(attestationRunnerFunc(func(context.Context, string, []string) ([]byte, []byte, error) { + return attestationJSON(fixture.digest), nil, nil + })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.gh) + if err == nil || !strings.Contains(err.Error(), "does not match the authenticated archive") { + t.Fatalf("mismatched existing extraction Verify = %v", err) + } + data, readErr := os.ReadFile(destination) + if readErr != nil || !bytes.Equal(data, want) { + t.Fatalf("mismatched existing extraction was changed: bytes=%q err=%v", data, readErr) + } } func TestVerifyAMD64AcceptsCanonicalDualArchitectureManifest(t *testing.T) { From ca9a6fdb765642905f1e5144d1d398e0cd4feb62 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:46:02 +0100 Subject: [PATCH 3/3] Make ARM64 archive fixtures complete --- internal/selfupdate/verify_test.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/selfupdate/verify_test.go b/internal/selfupdate/verify_test.go index 7a3aa00..f100f84 100644 --- a/internal/selfupdate/verify_test.go +++ b/internal/selfupdate/verify_test.go @@ -12,6 +12,7 @@ import ( "path/filepath" "reflect" "runtime" + "sort" "strings" "testing" ) @@ -280,7 +281,7 @@ func TestVerifyAMD64AcceptsCanonicalDualArchitectureManifest(t *testing.T) { } func TestVerifyARM64RejectsUnexpectedArchiveEntryAfterAuthentication(t *testing.T) { - fixture := newARM64VerificationFixture(t, map[string][]byte{"../escape": []byte("unsafe")}) + fixture := newARM64VerificationFixture(t, map[string][]byte{"unexpected.bin": []byte("unsafe")}) called := false _, err := testVerifier(attestationRunnerFunc(func(context.Context, string, []string) ([]byte, []byte, error) { called = true @@ -556,7 +557,16 @@ func arm64TestArchive(t *testing.T, extras map[string][]byte) []byte { for name, data := range extras { entries[name] = data } - for _, name := range []string{"cb.exe", "LICENSE", "README.md", "../escape"} { + names := []string{"cb.exe", "LICENSE", "README.md"} + var extraNames []string + for name := range extras { + if name != "cb.exe" && name != "LICENSE" && name != "README.md" { + extraNames = append(extraNames, name) + } + } + sort.Strings(extraNames) + names = append(names, extraNames...) + for _, name := range names { data, ok := entries[name] if !ok { continue