From 0e362309fe8af8a7ff5cc1109ed5929fa9fd43b9 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:44:53 +0900 Subject: [PATCH] fix: verify storage-first final release recovery --- cmd/relay/access_commands.go | 16 ++- cmd/relay/aws_grant_live_test.go | 12 ++ cmd/relay/aws_live_test.go | 7 +- cmd/relay/aws_storage_test.go | 17 ++- cmd/relay/docker_driver.go | 102 +++++++++++++++ cmd/relay/docker_driver_test.go | 54 ++++++++ cmd/relay/r2_scope_probe.go | 13 +- cmd/relay/storage_preflight.go | 2 +- cmd/relay/workflow_v4_full_live_test.go | 125 +++++++++++++++---- cmd/relay/workflow_v4_live_test.go | 64 +++++++++- cmd/relay/workflow_v4_release_signer.go | 20 ++- cmd/relay/workflow_v4_release_signer_test.go | 20 +++ docs/maintainer/aws.md | 9 ++ docs/maintainer/storage-tests.md | 55 +++++++- internal/transcript/commitments_v4.go | 12 ++ internal/transcript/inspect_v4_test.go | 33 +++++ release/release-notes.md | 40 ++++-- release/role-images.json | 8 +- scripts/storage-setup/setup-aws.sh | 2 +- 19 files changed, 552 insertions(+), 59 deletions(-) diff --git a/cmd/relay/access_commands.go b/cmd/relay/access_commands.go index 88e3b3c..5fefee7 100644 --- a/cmd/relay/access_commands.go +++ b/cmd/relay/access_commands.go @@ -155,6 +155,13 @@ func checkStorageObjects(config access.StorageConfig, clientFor func(store.Clien if got, err := os.ReadFile(authenticated); err != nil || !bytes.Equal(got, payload) { return errors.New("authenticated published read returned different probe bytes") } + versioned := filepath.Join(dir, "published-versioned") + if _, err := published.GetVersionedAtMost(key, versioned, int64(len(payload))); err != nil { + return fmt.Errorf("published bucket version-pinned read probe: %w", err) + } + if got, err := os.ReadFile(versioned); err != nil || !bytes.Equal(got, payload) { + return errors.New("version-pinned published read returned different probe bytes") + } public := clientFor(store.Client{PublicBaseURL: config.PublishedBaseURL}) var publicErr error for attemptNumber := 0; attemptNumber < 5; attemptNumber++ { @@ -185,6 +192,13 @@ func checkStorageObjects(config access.StorageConfig, clientFor func(store.Clien } } }() + versioned = filepath.Join(dir, "inbox-versioned") + if _, err := inbox.GetVersionedAtMost(key, versioned, int64(len(payload))); err != nil { + return fmt.Errorf("inbox bucket version-pinned read probe: %w", err) + } + if got, err := os.ReadFile(versioned); err != nil || !bytes.Equal(got, payload) { + return errors.New("version-pinned inbox read returned different probe bytes") + } if config.Provider != "r2" { unsigned := clientFor(store.Client{Endpoint: config.Endpoint, Region: config.Region, Bucket: config.InboxBucket, NoSign: true}) publiclyVisible, headErr := unsigned.Head(key) @@ -624,7 +638,7 @@ func issueAWSWithRunner(config access.StorageConfig, identity, prefix string, tt "Version": "2012-10-17", "Statement": []map[string]any{{ "Effect": "Allow", - "Action": []string{"s3:PutObject", "s3:GetObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"}, + "Action": []string{"s3:PutObject", "s3:GetObject", "s3:GetObjectVersion", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"}, "Resource": objectARN, }}, }) diff --git a/cmd/relay/aws_grant_live_test.go b/cmd/relay/aws_grant_live_test.go index 64cd0cd..a7981ef 100644 --- a/cmd/relay/aws_grant_live_test.go +++ b/cmd/relay/aws_grant_live_test.go @@ -26,6 +26,11 @@ func TestAWSLiveGrantScopeAndExpiry(t *testing.T) { t.Fatal(err) } requireAWSLiveConfiguration(t, config) + if os.Getenv("RELAY_AWS_LIVE_CREDENTIALS_FILE") == "" { + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", freshAWSLiveCredentials(t)) + } else { + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", os.Getenv("RELAY_AWS_LIVE_CREDENTIALS_FILE")) + } id, err := randomID() if err != nil { t.Fatal(err) @@ -69,6 +74,9 @@ func TestAWSLiveGrantScopeAndExpiry(t *testing.T) { if err := scoped.Get(base+"allowed/probe", allowed); err != nil { t.Fatal("allowed read failed") } + if _, err := scoped.GetVersionedAtMost(base+"allowed/probe", filepath.Join(dir, "allowed-versioned"), int64(len(data))); err != nil { + t.Fatalf("allowed version-pinned read failed: %v", err) + } got, err := os.ReadFile(allowed) if err != nil || !bytes.Equal(got, data) { t.Fatal("allowed bytes differ") @@ -90,6 +98,10 @@ func TestAWSLiveGrantScopeAndExpiry(t *testing.T) { t.Fatalf("outside write inconclusive; inspect %s/%s", cleaner.Bucket, key) } } + if os.Getenv("RELAY_AWS_LIVE_SCOPE_ONLY") == "1" { + t.Log("scoped grant and version-pinned read passed; expiry was intentionally not tested") + return + } t.Logf("Allowed read/write and outside-prefix/other-bucket denial passed; waiting until %s", expires.Add(5*time.Second).UTC().Format(time.RFC3339)) for time.Now().Before(expires.Add(5 * time.Second)) { remaining := time.Until(expires.Add(5 * time.Second)) diff --git a/cmd/relay/aws_live_test.go b/cmd/relay/aws_live_test.go index c5f98ab..1c1a66d 100644 --- a/cmd/relay/aws_live_test.go +++ b/cmd/relay/aws_live_test.go @@ -29,7 +29,12 @@ func TestAWSLiveIsolatedStoragePreflight(t *testing.T) { if _, err := settings.infrastructure(); err != nil { t.Fatal(err) } - credentials := require("RELAY_AWS_LIVE_CREDENTIALS_FILE") + credentials := os.Getenv("RELAY_AWS_LIVE_CREDENTIALS_FILE") + if credentials == "" { + // The isolated login wrapper can refresh a short-lived, protected test + // snapshot without exporting secret bytes through a shell or test log. + credentials = freshAWSLiveCredentials(t) + } if _, err := readProtectedCredentialBytes(credentials, 1<<20); err != nil { t.Fatal("dedicated AWS credentials file unavailable or unsafe") } diff --git a/cmd/relay/aws_storage_test.go b/cmd/relay/aws_storage_test.go index b8f813b..84f961a 100644 --- a/cmd/relay/aws_storage_test.go +++ b/cmd/relay/aws_storage_test.go @@ -45,7 +45,7 @@ func TestAWSGrantRequestsOnlyIntendedInboxPrefix(t *testing.T) { if len(policy.Statement) != 1 || policy.Statement[0].Effect != "Allow" || policy.Statement[0].Resource != "arn:aws:s3:::private-fixture/setup-probes/test/allowed/*" { t.Fatal("session policy escaped intended prefix") } - if strings.Join(policy.Statement[0].Action, ",") != "s3:PutObject,s3:GetObject,s3:AbortMultipartUpload,s3:ListMultipartUploadParts" { + if strings.Join(policy.Statement[0].Action, ",") != "s3:PutObject,s3:GetObject,s3:GetObjectVersion,s3:AbortMultipartUpload,s3:ListMultipartUploadParts" { t.Fatal("unexpected session permissions") } return json.Marshal(map[string]any{"Credentials": map[string]string{"AccessKeyId": "test-access", "SecretAccessKey": "test-secret", "SessionToken": "test-session", "Expiration": expires.Format(time.RFC3339)}}) @@ -61,7 +61,7 @@ func TestAWSGrantRequestsOnlyIntendedInboxPrefix(t *testing.T) { } func TestAWSStoragePreflightAndFailureCleanup(t *testing.T) { - for _, fault := range []string{"", "public-inbox", "inbox-not-found", "inbox-network-error", "authenticated-corruption", "public-corruption", "public-unavailable", "inbox-write-denied", "delete-denied", "ambiguous-write", "collision"} { + for _, fault := range []string{"", "public-inbox", "inbox-not-found", "inbox-network-error", "authenticated-corruption", "public-corruption", "public-unavailable", "version-denied", "inbox-write-denied", "delete-denied", "ambiguous-write", "collision"} { t.Run(fault, func(t *testing.T) { config, err := storageSettingsFixture().infrastructure() if err != nil { @@ -107,6 +107,19 @@ func TestAWSStoragePreflightAndFailureCleanup(t *testing.T) { } return os.WriteFile(file, raw, 0600) }, + GetVersionedAtMost: func(key, file string, maximum int64) (store.ObjectVersion, error) { + if fault == "version-denied" { + return store.ObjectVersion{}, errors.New("AccessDenied: s3:GetObjectVersion") + } + raw, ok := objects[bucket+"/"+key] + if !ok || int64(len(raw)) > maximum { + return store.ObjectVersion{}, errors.New("missing or oversized probe") + } + if err := os.WriteFile(file, raw, 0600); err != nil { + return store.ObjectVersion{}, err + } + return store.ObjectVersion{ETag: "probe", Size: int64(len(raw))}, nil + }, Head: func(key string) (bool, error) { if !c.NoSign || bucket != config.InboxBucket { t.Fatal("privacy check not anonymous or wrong bucket") diff --git a/cmd/relay/docker_driver.go b/cmd/relay/docker_driver.go index 6a6b93c..974bb38 100644 --- a/cmd/relay/docker_driver.go +++ b/cmd/relay/docker_driver.go @@ -738,6 +738,10 @@ func (d *dockerDriver) rewriteReadOnlyArgs(args []string) ([]string, []dockerMou } func (d *dockerDriver) rewriteArgs(args []string, writable map[string]string) ([]string, []dockerMount, error) { + artifactRoot, err := d.artifactRootMount(args) + if err != nil { + return nil, nil, err + } exact := map[string]string{ d.definition: "/relay/trust/ceremony.json", d.definitionSig: "/relay/trust/ceremony.sig", d.coordinatorKey: "/relay/trust/coordinator.hex", d.signingKey: "/relay/key/participant.key", @@ -748,6 +752,9 @@ func (d *dockerDriver) rewriteArgs(args []string, writable map[string]string) ([ } rewritten := append([]string(nil), args...) mountBySource := make(map[string]dockerMount) + if artifactRoot.source != "" { + mountBySource[artifactRoot.source] = dockerMount{Source: artifactRoot.source, Destination: artifactRoot.destination, ReadOnly: true} + } for i, arg := range rewritten { if mapped, ok := exact[arg]; ok && arg != "" { rewritten[i] = mapped @@ -755,6 +762,16 @@ func (d *dockerDriver) rewriteArgs(args []string, writable map[string]string) ([ continue } if filepath.IsAbs(arg) { + if artifactRoot.source != "" { + mapped, contained, pathErr := artifactRoot.child(arg) + if pathErr != nil { + return nil, nil, pathErr + } + if contained { + rewritten[i] = mapped + continue + } + } mapped, err := pathWithin(d.root, arg, "/relay/input") if err != nil { if d.inspectionRoot == "" || arg == d.inspectionRoot { @@ -787,6 +804,91 @@ func (d *dockerDriver) rewriteArgs(args []string, writable map[string]string) ([ return rewritten, mounts, nil } +type dockerArtifactRootMount struct { + root string // caller spelling, used for lexical child containment + source string + destination string +} + +// artifactRootMount recognizes the proof-tool's explicit public artifact root. +// Its contents must remain one real, read-only Docker mount so --checkpoint +// children resolve beneath --artifact-root inside the container. No other +// inspection-root directory receives this broader mounting behaviour. +func (d *dockerDriver) artifactRootMount(args []string) (dockerArtifactRootMount, error) { + var root string + for i := 0; i < len(args); i++ { + if args[i] != "--artifact-root" { + continue + } + if i+1 == len(args) || root != "" { + return dockerArtifactRootMount{}, errors.New("Docker inspection requires one explicit artifact root") + } + root = args[i+1] + i++ + } + if root == "" { + return dockerArtifactRootMount{}, nil + } + if !filepath.IsAbs(root) || filepath.Clean(root) != root { + return dockerArtifactRootMount{}, errors.New("Docker artifact root must be an absolute clean directory") + } + // The normal ceremony public root already has a dedicated input mount. + if _, err := pathWithin(d.root, root, "/relay/input"); err == nil { + return dockerArtifactRootMount{}, nil + } + if d.inspectionRoot == "" || root == d.inspectionRoot { + return dockerArtifactRootMount{}, errors.New("refuse unrecognized Docker artifact root") + } + if _, err := pathWithin(d.inspectionRoot, root, "/relay/extra"); err != nil { + return dockerArtifactRootMount{}, errors.New("refuse artifact root outside the inspection workspace") + } + info, err := os.Lstat(root) + if err != nil { + return dockerArtifactRootMount{}, err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return dockerArtifactRootMount{}, errors.New("Docker artifact root must be a real directory") + } + resolved, err := filepath.EvalSymlinks(root) + if err != nil { + return dockerArtifactRootMount{}, err + } + return dockerArtifactRootMount{root: root, source: filepath.Clean(resolved), destination: "/relay/artifacts"}, nil +} + +// child maps an existing non-symlink child under the explicit artifact-root +// mount. It rejects a symlink at any component, restoring the exact-path +// containment checks that separate mounts previously provided. +func (m dockerArtifactRootMount) child(path string) (string, bool, error) { + if m.source == "" { + return "", false, nil + } + mapped, err := pathWithin(m.root, path, m.destination) + if err != nil { + return "", false, nil + } + relative, err := filepath.Rel(m.root, path) + if err != nil { + return "", false, err + } + parts := strings.Split(relative, string(filepath.Separator)) + current := m.root + for _, part := range parts { + if part == "." || part == "" { + continue + } + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if err != nil { + return "", false, err + } + if info.Mode()&os.ModeSymlink != 0 { + return "", false, errors.New("Docker artifact arguments cannot traverse symbolic links") + } + } + return mapped, true, nil +} + func (d *dockerDriver) baseRunArgs(remove bool, mounts []dockerMount) []string { args := []string{"run"} if remove { diff --git a/cmd/relay/docker_driver_test.go b/cmd/relay/docker_driver_test.go index e7b1fa9..0f6cf1b 100644 --- a/cmd/relay/docker_driver_test.go +++ b/cmd/relay/docker_driver_test.go @@ -361,6 +361,60 @@ func TestDockerInspectionMountsOnlyExactRequestedWorkFile(t *testing.T) { } } +func TestDockerInspectionKeepsArtifactRootAndChildrenInOneMount(t *testing.T) { + work := t.TempDir() + root := filepath.Join(work, "ceremony", "public") + stage := filepath.Join(work, "sync", "artifacts") + checkpoint := filepath.Join(stage, "checkpoints", "final", "checkpoint.json") + if err := os.MkdirAll(filepath.Dir(checkpoint), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(checkpoint, []byte("fixture"), 0o600); err != nil { + t.Fatal(err) + } + driver := dockerDriver{root: root, inspectionRoot: work} + // The checkpoint intentionally comes first: proof-tool command ordering must + // not decide whether it remains below the explicit artifact root. + rewritten, mounts, err := driver.rewriteReadOnlyArgs([]string{"--checkpoint", checkpoint, "--artifact-root", stage}) + if err != nil { + t.Fatal(err) + } + resolvedStage, err := filepath.EvalSymlinks(stage) + if err != nil { + t.Fatal(err) + } + if len(mounts) != 1 || mounts[0].Source != resolvedStage || !mounts[0].ReadOnly { + t.Fatalf("artifact root was not mounted once read-only: %+v", mounts) + } + if want := "/relay/artifacts"; rewritten[3] != want { + t.Fatalf("artifact root = %q, want %q", rewritten[3], want) + } + if want := "/relay/artifacts/checkpoints/final/checkpoint.json"; rewritten[1] != want { + t.Fatalf("checkpoint escaped its artifact-root mount: got %q, want %q", rewritten[3], want) + } +} + +func TestDockerInspectionRejectsSymlinkUnderArtifactRoot(t *testing.T) { + work := t.TempDir() + root := filepath.Join(work, "ceremony", "public") + stage := filepath.Join(work, "sync", "artifacts") + if err := os.MkdirAll(stage, 0o700); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "checkpoint.json") + if err := os.WriteFile(outside, []byte("fixture"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(stage, "checkpoint.json") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + driver := dockerDriver{root: root, inspectionRoot: work} + if _, _, err := driver.rewriteReadOnlyArgs([]string{"--artifact-root", stage, "--checkpoint", link}); err == nil { + t.Fatal("accepted an artifact child symlink") + } +} + func TestDockerContributionAdoptsContainerAfterLostCreateResponse(t *testing.T) { o, pos, driver, fake := dockerContributionFixture(t) fake.createErrAfter = true diff --git a/cmd/relay/r2_scope_probe.go b/cmd/relay/r2_scope_probe.go index 6ac8d3d..965acd9 100644 --- a/cmd/relay/r2_scope_probe.go +++ b/cmd/relay/r2_scope_probe.go @@ -15,15 +15,16 @@ import ( ) type scopeProbeStore struct { - Bucket string - PutNoReplace func(string, string) error - Get func(string, string) error - Delete func(string) error - Head func(string) (bool, error) + Bucket string + PutNoReplace func(string, string) error + Get func(string, string) error + GetVersionedAtMost func(string, string, int64) (store.ObjectVersion, error) + Delete func(string) error + Head func(string) (bool, error) } func newScopeProbeStore(c store.Client) scopeProbeStore { - return scopeProbeStore{Bucket: c.Bucket, PutNoReplace: c.PutNoReplace, Get: c.Get, Delete: c.Delete, Head: c.Head} + return scopeProbeStore{Bucket: c.Bucket, PutNoReplace: c.PutNoReplace, Get: c.Get, GetVersionedAtMost: c.GetVersionedAtMost, Delete: c.Delete, Head: c.Head} } // Only fresh random probe keys are touched, including denial tests. No listing diff --git a/cmd/relay/storage_preflight.go b/cmd/relay/storage_preflight.go index a3972d2..0ce330b 100644 --- a/cmd/relay/storage_preflight.go +++ b/cmd/relay/storage_preflight.go @@ -61,7 +61,7 @@ func runCheckStorage(args []string) error { if err != nil || digest != after { return errors.New("infrastructure settings changed during checks") } - checks := []string{"published object write and authenticated read", "anonymous published read with exact bytes", "inbox object write", "probe deletion"} + checks := []string{"published object write and authenticated version-pinned read", "anonymous published read with exact bytes", "inbox object write and authenticated version-pinned read", "probe deletion"} if c.Provider == "r2" { checks = append(checks, "R2 inbox has no enabled managed or custom public domain") checks = append(checks, "inbox parent read/write denied on the selected public bucket; other account buckets were not tested") diff --git a/cmd/relay/workflow_v4_full_live_test.go b/cmd/relay/workflow_v4_full_live_test.go index cb79105..dd0c3d5 100644 --- a/cmd/relay/workflow_v4_full_live_test.go +++ b/cmd/relay/workflow_v4_full_live_test.go @@ -49,6 +49,12 @@ func TestV4LiveFullR2Journey(t *testing.T) { if offlineImage == "" || onlineImage == "" || relayBinary == "" || configPath == "" || credentialsPath == "" || parentPath == "" || controlPath == "" || proofBinary == "" { t.Skip("set the V4 live R2 images, config, three credential paths, and native Relay and proof-tool binaries") } + // Live development runs may start from locally named images. Resolve those + // names once and pass only immutable image IDs to every ceremony command. + // This retains the production rejection of mutable tags while preventing a + // long rehearsal from depending on a tag that can move during the run. + offlineImage = workflowV4LiveImmutableImage(t, offlineImage) + onlineImage = workflowV4LiveImmutableImage(t, onlineImage) for _, path := range []string{relayBinary, configPath, credentialsPath, parentPath, controlPath, proofBinary} { if !filepath.IsAbs(path) || filepath.Clean(path) != path { t.Fatal("live test paths must be absolute and clean") @@ -267,10 +273,7 @@ func TestV4LiveFullR2Journey(t *testing.T) { copyWorkflowV4LiveFile(t, filepath.Join(ceremonyRoot, name), filepath.Join(freshTranscript, name)) } copyWorkflowV4LiveFile(t, filepath.Join(coordinator.profile.Trust, "setup-coordinator.hex"), filepath.Join(freshTrust, "coordinator-public-key.hex")) - freshInspector := transcript.Inspector{ - Executable: proofBinary, CeremonyPath: filepath.Join(freshTranscript, "ceremony.json"), CeremonySignaturePath: filepath.Join(freshTranscript, "ceremony.sig"), - CoordinatorPublicKeyPath: filepath.Join(freshTrust, "coordinator-public-key.hex"), TranscriptRoot: freshTranscript, - } + freshInspector := workflowV4LiveFreshInspector(t, coordinator.profile, freshRoot, freshTranscript, freshTrust) highWater, err := state.OpenWorkspaceHighWater(filepath.Join(freshRoot, "high-water"), protocol.Definition.CeremonyID) if err != nil { t.Fatal(err) @@ -286,6 +289,64 @@ func TestV4LiveFullR2Journey(t *testing.T) { t.Logf("completed and freshly reconstructed live R2 V4 ceremony %s at signed update %d", protocol.Definition.CeremonyID, stateView.Sequence) } +func workflowV4LiveImmutableImage(t *testing.T, image string) string { + t.Helper() + id, resolved, err := workflowV4LiveImmutableImageWithRunner(image, func(name string) ([]byte, error) { + return exec.Command("docker", "image", "inspect", "--format", "{{.Id}}", name).CombinedOutput() + }) + if err != nil { + t.Fatal(err) + } + if resolved { + t.Logf("resolved local live-test image %q to immutable %s", image, id) + } + return id +} + +func workflowV4LiveImmutableImageWithRunner(image string, inspect func(string) ([]byte, error)) (string, bool, error) { + valid := func(value string) bool { + if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + return err == nil + } + if valid(image) { + return image, false, nil + } + output, err := inspect(image) + if err != nil { + return "", false, fmt.Errorf("resolve live-test image %q to its immutable local ID: %w: %s", image, err, output) + } + id := strings.TrimSpace(string(output)) + if !valid(id) { + return "", false, fmt.Errorf("docker returned an invalid immutable image ID for %q", image) + } + return id, true, nil +} + +func TestWorkflowV4LiveImageTagsResolveOnceToImmutableIDs(t *testing.T) { + id := "sha256:" + strings.Repeat("a", 64) + calls := 0 + got, resolved, err := workflowV4LiveImmutableImageWithRunner("local-live:arm64", func(name string) ([]byte, error) { + calls++ + if name != "local-live:arm64" { + t.Fatalf("inspected image %q", name) + } + return []byte(id + "\n"), nil + }) + if err != nil || !resolved || got != id || calls != 1 { + t.Fatalf("tag resolution = (%q, %t, %v, calls=%d)", got, resolved, err, calls) + } + got, resolved, err = workflowV4LiveImmutableImageWithRunner(id, func(string) ([]byte, error) { + t.Fatal("immutable ID must not be resolved again") + return nil, nil + }) + if err != nil || resolved || got != id { + t.Fatalf("immutable input = (%q, %t, %v)", got, resolved, err) + } +} + // TestV4LiveResumeR2Release is an opt-in recovery check for a live journey // that already reached the frozen release review. It intentionally reopens the // original journals and runtimes rather than inventing replacement profiles, @@ -361,27 +422,33 @@ func TestV4LiveResumeR2Release(t *testing.T) { } } coordinatorSnapshot := syncWorkflowV4LiveRole(t, &coordinator, objects) - grant, grantPath, err := workflowV4CurrentReleaseGrant(coordinatorSnapshot, protocol, config, coordinator.profile.Work, releaseSigner.identity.ID, time.Now().UTC()) - if err != nil || grant == nil || grantPath == "" { - t.Fatalf("retained release grant is unavailable: %v", err) + coordinatorState, err := coordinatorSnapshot.State() + if err != nil { + t.Fatal(err) } - received := filepath.Join(coordinator.profile.Work, "workflow-v4", "coordinator", "release", "received", grant.AttemptID) - if _, err := os.Lstat(received); errors.Is(err, os.ErrNotExist) { - if err := runWorkflowV4ReleaseSignerAction(workflowV4LiveUI(grantPath+"\nUPLOAD RELEASE PACKAGE\n"), releaseSnapshot, protocol, config, releaseSigner.profile, releaseSigner.signer, releaseSigner.identity, progress); err != nil { + if coordinatorState.Progress.FinalRelease == nil { + grant, grantPath, err := workflowV4CurrentReleaseGrant(coordinatorSnapshot, protocol, config, coordinator.profile.Work, releaseSigner.identity.ID, time.Now().UTC()) + if err != nil || grant == nil || grantPath == "" { + t.Fatalf("retained release grant is unavailable: %v", err) + } + received := filepath.Join(coordinator.profile.Work, "workflow-v4", "coordinator", "release", "received", grant.AttemptID) + if _, err := os.Lstat(received); errors.Is(err, os.ErrNotExist) { + if err := runWorkflowV4ReleaseSignerAction(workflowV4LiveUI(grantPath+"\nUPLOAD RELEASE PACKAGE\n"), releaseSnapshot, protocol, config, releaseSigner.profile, releaseSigner.signer, releaseSigner.identity, progress); err != nil { + t.Fatal(err) + } + } else if err != nil { + t.Fatal(err) + } + coordinatorSnapshot = syncWorkflowV4LiveRole(t, &coordinator, objects) + input := "CHECK RELEASE INBOX\nVERIFY AND RECORD RELEASE\n" + if _, err := os.Lstat(received); err == nil { + input = "VERIFY AND RECORD RELEASE\n" + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + if err := runWorkflowV4CoordinatorLifecycle(workflowV4LiveUI(input), workflowV4Release, coordinatorSnapshot, protocol, coordinator.profile, coordinator.signer, coordinator.inspector); err != nil { t.Fatal(err) } - } else if err != nil { - t.Fatal(err) - } - coordinatorSnapshot = syncWorkflowV4LiveRole(t, &coordinator, objects) - input := "CHECK RELEASE INBOX\nVERIFY AND RECORD RELEASE\n" - if _, err := os.Lstat(received); err == nil { - input = "VERIFY AND RECORD RELEASE\n" - } else if !errors.Is(err, os.ErrNotExist) { - t.Fatal(err) - } - if err := runWorkflowV4CoordinatorLifecycle(workflowV4LiveUI(input), workflowV4Release, coordinatorSnapshot, protocol, coordinator.profile, coordinator.signer, coordinator.inspector); err != nil { - t.Fatal(err) } freshRoot := t.TempDir() @@ -391,7 +458,7 @@ func TestV4LiveResumeR2Release(t *testing.T) { copyWorkflowV4LiveFile(t, filepath.Join(ceremonyRoot, name), filepath.Join(freshTranscript, name)) } copyWorkflowV4LiveFile(t, filepath.Join(coordinator.profile.Trust, "setup-coordinator.hex"), filepath.Join(freshTrust, "coordinator-public-key.hex")) - freshInspector := transcript.Inspector{Executable: proofBinary, CeremonyPath: filepath.Join(freshTranscript, "ceremony.json"), CeremonySignaturePath: filepath.Join(freshTranscript, "ceremony.sig"), CoordinatorPublicKeyPath: filepath.Join(freshTrust, "coordinator-public-key.hex"), TranscriptRoot: freshTranscript} + freshInspector := workflowV4LiveFreshInspector(t, coordinator.profile, freshRoot, freshTranscript, freshTrust) highWater, err := state.OpenWorkspaceHighWater(filepath.Join(freshRoot, "high-water"), protocol.Definition.CeremonyID) if err != nil { t.Fatal(err) @@ -407,6 +474,18 @@ func TestV4LiveResumeR2Release(t *testing.T) { t.Logf("resumed and freshly reconstructed live R2 V4 ceremony %s at signed update %d", protocol.Definition.CeremonyID, stateView.Sequence) } +func workflowV4LiveFreshInspector(t *testing.T, profile guidedProfile, freshRoot, transcriptRoot, trustRoot string) transcript.Inspector { + t.Helper() + driver := dockerDriver{ + image: profile.Image, platform: profile.Platform, ceremonyBinary: "/usr/local/bin/mpc-ceremony", + root: transcriptRoot, inspectionRoot: freshRoot, + definition: filepath.Join(transcriptRoot, "ceremony.json"), definitionSig: filepath.Join(transcriptRoot, "ceremony.sig"), + coordinatorKey: filepath.Join(trustRoot, "coordinator-public-key.hex"), + client: osDockerCommandClient{binary: workflowV4LiveDockerCLI(t)}, + } + return driver.inspector() +} + func reopenWorkflowV4LiveRole(t *testing.T, root, role string, protocol transcript.DefinitionProtocol) workflowV4LiveRole { t.Helper() base := filepath.Join(root, role) diff --git a/cmd/relay/workflow_v4_live_test.go b/cmd/relay/workflow_v4_live_test.go index 4d5290d..357d6a4 100644 --- a/cmd/relay/workflow_v4_live_test.go +++ b/cmd/relay/workflow_v4_live_test.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "bytes" "errors" "fmt" "io" @@ -45,6 +46,36 @@ func TestV4LiveInitialR2(t *testing.T) { if err != nil || base.Provider != "r2" { t.Fatalf("live test requires an existing valid R2 configuration: %v", err) } + runV4LiveInitialStorage(t, base, credentialsPath, proofBinary, image, onlineImage) +} + +// TestV4LiveInitialAWS runs only the signed initial-state storage journey. It +// does not repeat either contribution phase or claim a full AWS ceremony. The +// isolated login must resolve to the explicitly named dedicated test identity. +func TestV4LiveInitialAWS(t *testing.T) { + if os.Getenv("RELAY_AWS_LIVE_PROBES_APPROVED") != "1" { + t.Skip("requires explicit dedicated AWS test approval") + } + var settings coordinatorStorageSettings + if err := setupReadJSON(os.Getenv("RELAY_AWS_LIVE_SETTINGS_FILE"), &settings); err != nil { + t.Fatal(err) + } + base, err := settings.infrastructure() + if err != nil { + t.Fatal(err) + } + requireAWSLiveConfiguration(t, base) + credentials := freshAWSLiveCredentials(t) + runV4LiveInitialStorage(t, base, credentials, os.Getenv("RELAY_V4_LIVE_PROOF_BINARY"), os.Getenv("RELAY_PREPARE_TEST_IMAGE"), os.Getenv("RELAY_V4_LIVE_ONLINE_IMAGE")) +} + +func runV4LiveInitialStorage(t *testing.T, base access.StorageConfig, credentialsPath, proofBinary, image, onlineImage string) { + t.Helper() + if image == "" || onlineImage == "" || credentialsPath == "" || proofBinary == "" { + t.Fatal("live initial-state test requires both images, credentials and a native proof-tool companion") + } + image = workflowV4LiveImmutableImage(t, image) + onlineImage = workflowV4LiveImmutableImage(t, onlineImage) w := setupFixture(t) // On macOS the live test authenticates the initialized Linux ceremony with // a separately built native verifier. Include that exact companion in the @@ -96,6 +127,7 @@ func TestV4LiveInitialR2(t *testing.T) { t.Fatal(err) } config := base + config.Schema = access.StorageConfigSchema config.CeremonyID = protocol.Definition.CeremonyID config.CeremonyPath = "/work/ceremony/public/ceremony.json" config.CeremonySignature = "/work/ceremony/public/ceremony.sig" @@ -148,7 +180,37 @@ func TestV4LiveInitialR2(t *testing.T) { if checkpoint.Sequence != 0 || checkpoint.Transition.Kind != "initial" || snapshot.Checked() != 1 { t.Fatalf("unexpected authenticated live initial state: sequence=%d transition=%s history=%d", checkpoint.Sequence, checkpoint.Transition.Kind, snapshot.Checked()) } - t.Logf("authenticated live R2 initial V4 state for ceremony %s", protocol.Definition.CeremonyID) + if base.Provider == "aws" { + // Use the same dedicated coordinator snapshot to prove that a conflicting + // blob create and a stale root CAS cannot replace this signed state. + published := coordinatorClient(config, config.PublishedBucket) + conflictDir := t.TempDir() + conflict := filepath.Join(conflictDir, "conflict") + if err := os.WriteFile(conflict, []byte("different synthetic smoke-test bytes\n"), 0o600); err != nil { + t.Fatal(err) + } + root, _ := snapshot.Root() + if err := published.PutNoReplace(store.Key(root.Checkpoint.SHA256), conflict); !errors.Is(err, store.ErrExists) { + t.Fatalf("conflicting immutable write was not rejected: %v", err) + } + if _, err := published.PutIfMatch(state.RootKey(config.CeremonyID), conflict, store.ObjectVersion{ETag: "\"00000000000000000000000000000000\""}); !errors.Is(err, store.ErrVersionConflict) { + t.Fatalf("stale root replacement was not rejected: %v", err) + } + rootBytes, err := root.Encode() + if err != nil { + t.Fatal(err) + } + actual := filepath.Join(conflictDir, "root-after-conflicts") + if _, err := published.GetVersionedAtMost(state.RootKey(config.CeremonyID), actual, 1<<20); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(actual) + if err != nil || !bytes.Equal(got, rootBytes) { + t.Fatal("AWS root changed after rejected conflicting writes") + } + t.Log("AWS rejected a conflicting immutable blob and stale root update; exact signed root bytes remain current") + } + t.Logf("authenticated live %s initial V4 state for ceremony %s", base.Provider, protocol.Definition.CeremonyID) } // TestV4LiveReleaseR2 is an explicit live-provider check for the last private diff --git a/cmd/relay/workflow_v4_release_signer.go b/cmd/relay/workflow_v4_release_signer.go index 60940f7..9a3f7a4 100644 --- a/cmd/relay/workflow_v4_release_signer.go +++ b/cmd/relay/workflow_v4_release_signer.go @@ -77,9 +77,9 @@ func runWorkflowV4ReleaseSigning(ui *coordinatorWizard, snapshot storagefirst.Sn if err != nil { return err } - head := stateView.Progress.ReleaseReview - if head == nil { - return errors.New("release review is not present") + checkpoint, err := workflowV4ReleaseReviewCheckpoint(snapshot.Head(), stateView.Progress.ReleaseReview) + if err != nil { + return err } if err := os.MkdirAll(filepath.Dir(progress.ReviewReport), 0o700); err != nil { return err @@ -101,7 +101,7 @@ func runWorkflowV4ReleaseSigning(ui *coordinatorWizard, snapshot storagefirst.Sn return errors.New("retained release review time is invalid; preserve it for inspection") } } else { - command, err := workflowV4ReleaseReviewCommand(online, signer, *head, progress.ReviewReport, releasedAt) + command, err := workflowV4ReleaseReviewCommand(online, signer, checkpoint, progress.ReviewReport, releasedAt) if err != nil { return err } @@ -112,7 +112,7 @@ func runWorkflowV4ReleaseSigning(ui *coordinatorWizard, snapshot storagefirst.Sn if err := ui.confirm("The approved proof-tool verified the exact coordinator review checkpoint and its bound full replay. Sign only this exact package", "SIGN RELEASE PACKAGE"); err != nil { return err } - command, err := workflowV4ReleaseSignCommand(online, signer, *head, progress.PackageDir, expected.Identity.KeyID, releasedAt) + command, err := workflowV4ReleaseSignCommand(online, signer, checkpoint, progress.PackageDir, expected.Identity.KeyID, releasedAt) if err != nil { return err } @@ -123,6 +123,16 @@ func runWorkflowV4ReleaseSigning(ui *coordinatorWizard, snapshot storagefirst.Sn return nil } +func workflowV4ReleaseReviewCheckpoint(checkpoint transcript.SignedArtifactRefs, operationalBundle *transcript.SignedArtifactRefs) (transcript.SignedArtifactRefs, error) { + if operationalBundle == nil { + return transcript.SignedArtifactRefs{}, errors.New("release review is not present") + } + if checkpoint.Record.Name == operationalBundle.Record.Name || checkpoint.Signature.Name == operationalBundle.Signature.Name { + return transcript.SignedArtifactRefs{}, errors.New("current release-review checkpoint is not distinct from its operational evidence bundle") + } + return checkpoint, nil +} + func workflowV4ReleaseReviewCommand(online, signer guidedProfile, head transcript.SignedArtifactRefs, out string, releasedAt time.Time) ([]string, error) { root := filepath.Join(online.Work, "ceremony", "public") mapWork := func(path string) (string, error) { return pathWithin(online.Work, path, "/work") } diff --git a/cmd/relay/workflow_v4_release_signer_test.go b/cmd/relay/workflow_v4_release_signer_test.go index 8feadab..1c17282 100644 --- a/cmd/relay/workflow_v4_release_signer_test.go +++ b/cmd/relay/workflow_v4_release_signer_test.go @@ -6,8 +6,28 @@ import ( "strings" "testing" "time" + + "github.com/zksecurity/relay/internal/transcript" ) +func TestWorkflowV4ReleaseSigningUsesCurrentCheckpointNotEvidenceBundle(t *testing.T) { + checkpoint := pairV4Test("checkpoints/final/review-1/checkpoint") + bundle := pairV4Test("operational/evidence-bundle") + got, err := workflowV4ReleaseReviewCheckpoint(checkpoint, &bundle) + if err != nil { + t.Fatal(err) + } + if got != checkpoint { + t.Fatalf("release checkpoint = %#v, want authenticated current head %#v", got, checkpoint) + } + if _, err := workflowV4ReleaseReviewCheckpoint(bundle, &bundle); err == nil { + t.Fatal("operational evidence bundle accepted as the release-review checkpoint") + } + if _, err := workflowV4ReleaseReviewCheckpoint(checkpoint, (*transcript.SignedArtifactRefs)(nil)); err == nil { + t.Fatal("missing operational evidence bundle accepted") + } +} + func TestWorkflowV4ReleaseCommandsBindFrozenReviewAndSeparateOutput(t *testing.T) { work, trust, keys := t.TempDir(), t.TempDir(), t.TempDir() online := guidedProfile{Work: work, Trust: trust, Keys: keys} diff --git a/docs/maintainer/aws.md b/docs/maintainer/aws.md index 016b092..eae043a 100644 --- a/docs/maintainer/aws.md +++ b/docs/maintainer/aws.md @@ -52,6 +52,15 @@ The script never creates access keys, edits the selected profile's permission set, or deletes cloud resources. It can incur AWS charges. Use a dedicated AWS account for a rehearsal when possible. +The coordinator/issuer profile needs `s3:GetObjectVersion` as well as +`s3:GetObject` for both ceremony buckets. Relay pins authenticated reads to +the exact S3 version returned by its preceding HEAD request. The short-lived +inbox role created by the setup script receives `s3:GetObjectVersion` only +within its assigned upload prefix; administrator-managed existing grant roles +must provide the same restricted permission. Ordinary storage probes may pass +without this permission, so verify a signed V4 state publication before using +an existing policy for a ceremony. + For a role already prepared by an administrator, set `USE_EXISTING_GRANT_ROLE=yes` in the setup config. Setup checks its caller-only trust and session duration before cloud writes, and skips all IAM changes. This metadata check does not diff --git a/docs/maintainer/storage-tests.md b/docs/maintainer/storage-tests.md index 793d374..f41c9eb 100644 --- a/docs/maintainer/storage-tests.md +++ b/docs/maintainer/storage-tests.md @@ -20,12 +20,18 @@ Obtain approval for temporary writes in two **existing test buckets**. Do not create buckets, change IAM policies or enable public access as part of this test. Provide a dedicated owner-only AWS credentials file containing only the test coordinator/issuer profiles, plus administrator-prepared non-secret settings. -SSO/credential-process configurations are not handled by this file-only lane. +Alternatively, use the isolated test AWS CLI wrapper after a fresh interactive +login; the test obtains a short-lived protected snapshot without printing +credentials. Never use the machine's default company AWS profile. Set these non-secret environment variables: - `RELAY_AWS_LIVE_SETTINGS_FILE`: absolute administrator settings JSON path. - `RELAY_AWS_LIVE_CREDENTIALS_FILE`: absolute dedicated protected file path. + Omit only when using the isolated wrapper below. +- `RELAY_AWS_TEST_CLI`, `RELAY_AWS_LIVE_EXPECTED_ACCOUNT`, and + `RELAY_AWS_LIVE_EXPECTED_PRINCIPAL`: required together for the isolated + wrapper path; the test rejects any other logged-in identity. - `RELAY_ROLE_ONLINE_IMAGE`: exact test image digest containing these changes. - `RELAY_ROLE_PLATFORM`: `linux/amd64` or `linux/arm64`. - `RELAY_AWS_LIVE_PROBES_APPROVED=1`: explicit approval for isolated probe writes. @@ -41,11 +47,22 @@ read-only credential mounts. It checks write/read/public-read/private-inbox behavior and deletion. Inspect exact reported keys after ambiguous writes or cleanup failures; never delete a whole bucket or ceremony prefix. -This lane does **not** test STS grant scope or actual credential expiry. -Those require separate live grant checks; AWS role sessions last at least +This lane now tests version-pinned coordinator reads from both buckets. It does +**not** test STS grant scope or actual credential expiry. Those require separate +live grant checks; AWS role sessions last at least [15 minutes](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html). Normal CI skips all live tests. Never put credentials in logs, arguments or PRs. +For a short signed-state provider check, set the same isolated wrapper/account +variables, `RELAY_PREPARE_TEST_IMAGE`, `RELAY_V4_LIVE_ONLINE_IMAGE`, and +`RELAY_V4_LIVE_PROOF_BINARY` (the exact-source native companion), then run +`go test ./cmd/relay -run '^TestV4LiveInitialAWS$' -count=1 -v`. This creates +a fresh tiny signed definition, publishes its initial checkpoint and required +public files to the dedicated AWS test bucket, verifies them from an empty +workspace, and checks conflicting blob/root writes are rejected. It does not +compute participant contributions. The signed synthetic test state remains in +the dedicated versioned bucket for inspection; there is no broad cleanup. + ## Dedicated-account follow-up lanes `TestAWSLiveGrantScopeAndExpiry` exercises real 15-minute STS expiry and narrowly @@ -53,6 +70,10 @@ scoped read/write denials on fresh synthetic keys. Its coordinator control login must remain valid through expiry and cleanup; an expired control makes the result inconclusive. Set `RELAY_AWS_LIVE_EXPECTED_ACCOUNT` explicitly; the fixture only accepts `relay-test-` bucket/role names in that account. Approval is still required. +For a fast scope check without waiting for expiry, set +`RELAY_AWS_LIVE_SCOPE_ONLY=1`; the test also requires the allowed prefix's +version-pinned read and explicitly reports that expiry was skipped. The isolated +test wrapper can refresh the protected coordinator snapshot in either mode. `TestRoleFlowDockerFullCeremony` with `RELAY_FLOW_AWS_APPROVED=1` sends the six candidate contributions through scoped AWS grants, then downloads, verifies, @@ -70,6 +91,34 @@ The ceremony lane also requires `RELAY_AWS_TEST_CLI` (absolute isolated wrapper) ## Live R2 result +The opt-in full V4 R2 journey accepts local image names for developer builds, +but resolves each name to one immutable local `sha256:` image ID before ceremony +setup. All subsequent commands use that ID; production commands continue to +reject mutable tags. Its native inspection binary must be built from the exact +Proof-tool release commit, with clean VCS metadata and the same Go version, +CGO and trim-path settings as the Linux release binaries. A convenient local +build that omits `vcs.revision` is not an authenticated companion binary and +must fail before initialization. + +Keep these inputs distinct: + +- the approved Linux Proof-tool binary is inside each role image and performs + ceremony work; +- `RELAY_V4_LIVE_PROOF_BINARY` is the exact-source native companion used only + by the macOS test controller and signed into the mixed-platform allowlist. + +Do not substitute one path for the other or rebuild the native companion from +a dirty checkout. + +### Follow-up: bounded parallel immutable transfers + +The current full-release lane transfers immutable objects serially. That is +safe but slow for a release package with many small files. After this release, +add bounded parallel uploads/downloads while preserving create-only writes, +manifest-last publication, exact byte/hash reconciliation, deterministic error +reporting, and safe retry after an interrupted batch. Do not make this +performance work a prerequisite for authenticating or resuming a ceremony. + On 2026-09-08, `TestR2LiveIsolatedStoragePreflight` passed in 56.33 seconds using a local Linux/ARM64 development image. It checked public object bytes, inbox domain privacy, parent denial on the selected published bucket, temporary diff --git a/internal/transcript/commitments_v4.go b/internal/transcript/commitments_v4.go index 5d245b8..fa51b1b 100644 --- a/internal/transcript/commitments_v4.go +++ b/internal/transcript/commitments_v4.go @@ -188,6 +188,14 @@ func (i Inspector) CheckpointGuidanceV4(root, record, signature string) (Checkpo return CheckpointInspectionV4{}, EnrollmentMetadataInspectionV4{}, errors.New("missing combined checkpoint guidance inspection") } c, err := validateStoredInspectionV4(*r.CheckpointInspectionV4) + // The first released V4 proof-tool populated the final download inventory + // in verify-stored-v4 but omitted it from the combined enrollment guidance + // response. Preserve those frozen runtimes by asking that same approved + // binary for its stricter stored projection. Nothing is accepted from the + // empty projection, and current binaries do not take this fallback. + if err != nil && releasedFinalGuidanceInventoryMissingV4(*r.CheckpointInspectionV4) { + c, err = i.StoredCheckpointV4(root, record, signature) + } if err != nil { return CheckpointInspectionV4{}, EnrollmentMetadataInspectionV4{}, err } @@ -206,6 +214,10 @@ func (i Inspector) CheckpointGuidanceV4(root, record, signature string) (Checkpo return c, e, nil } +func releasedFinalGuidanceInventoryMissingV4(p CheckpointInspectionV4) bool { + return p.Checkpoint.Progress.FinalRelease != nil && len(p.Commitments.FinalReleaseArtifacts) == 0 +} + func validateEnrollmentMetadataV4(p EnrollmentMetadataInspectionV4) (EnrollmentMetadataInspectionV4, error) { if p.Schema != "proof-tool-mpc-enrollment-metadata-v4" || p.Depth != "committed-enrollment-signatures" || !p.EnrollmentSignaturesVerified || p.DisclosureContentsVerified || p.CompleteRosterVerified || p.GlobalFreshnessVerified || !taggedHash(p.Metadata.CeremonyID, "sha256:") || p.Metadata.Enrollments == nil || len(p.Metadata.Enrollments) > 128 { return EnrollmentMetadataInspectionV4{}, errors.New("invalid enrollment metadata verification boundary") diff --git a/internal/transcript/inspect_v4_test.go b/internal/transcript/inspect_v4_test.go index d7bc7e7..31259dc 100644 --- a/internal/transcript/inspect_v4_test.go +++ b/internal/transcript/inspect_v4_test.go @@ -102,6 +102,39 @@ func TestStoredCheckpointV4RejectsMalformedProgress(t *testing.T) { } } +func TestCheckpointGuidanceV4UsesStrictReleasedFinalInventoryFallback(t *testing.T) { + pair := SignedArtifactRefs{Record: inspectionTestRef("checkpoints/final.json"), Signature: inspectionTestRef("checkpoints/final.sig")} + definition := SignedArtifactRefs{Record: inspectionTestRef("ceremony.json"), Signature: inspectionTestRef("ceremony.sig")} + release := SignedArtifactRefs{Record: inspectionTestRef("final/release/manifest.json"), Signature: inspectionTestRef("final/release/manifest.sig")} + c := CheckpointStateV4{Schema: "proof-tool-mpc-checkpoint-v4", Workflow: "storage-first-v2", ReleaseVerification: "coordinator-full-replay-v1", CeremonyID: "sha256:" + hex64, Definition: definition, AcceptedArtifacts: []ArtifactRef{}, Deliveries: []DeliverySlotV4{}} + c.Progress.Phase1 = CheckpointPhaseState{Phase: "phase1", HeadRecordID: "sha256:" + hex64, HeadPayload: inspectionTestRef("phase1/genesis.bin"), Chain: definition} + c.Progress.FinalRelease = &release + empty := CheckpointInspectionV4{Schema: "proof-tool-mpc-checkpoint-inspection-v4", Depth: "checkpoint-structure", Checkpoint: c, CheckpointRefs: pair, Commitments: CheckpointCommitmentsV4{Enrollments: []SignedArtifactRefs{}, Turns: []TurnCommitmentV4{}, FinalReleaseArtifacts: []ArtifactRef{}}} + full := empty + full.Commitments.FinalReleaseArtifacts = []ArtifactRef{inspectionTestRef("final/release/setup-transcript.json")} + metadata := EnrollmentMetadataInspectionV4{Schema: "proof-tool-mpc-enrollment-metadata-v4", Depth: "committed-enrollment-signatures", Metadata: EnrollmentMetadataV4{CeremonyID: c.CeremonyID, Checkpoint: pair, Enrollments: []CommittedEnrollmentMetadataV4{}}, EnrollmentSignaturesVerified: true} + i := testInspector() + calls := 0 + i.run = func(executable string, args ...string) ([]byte, []byte, error) { + calls++ + var result inspectionResult + if strings.Contains(strings.Join(args, " "), "inspect-enrollments-v4") { + result = inspectionResult{Schema: commandResultSchema, OK: true, Command: "checkpoint inspect-enrollments-v4", CheckpointInspectionV4: &empty, EnrollmentMetadataV4: &metadata} + } else { + result = inspectionResult{Schema: commandResultSchema, OK: true, Command: "checkpoint verify-stored-v4", CheckpointInspectionV4: &full} + } + raw, err := json.Marshal(result) + return raw, nil, err + } + got, _, err := i.CheckpointGuidanceV4("/stage", "/stage/final.json", "/stage/final.sig") + if err != nil { + t.Fatal(err) + } + if calls != 2 || !reflect.DeepEqual(got.Commitments.FinalReleaseArtifacts, full.Commitments.FinalReleaseArtifacts) { + t.Fatalf("released-runtime fallback calls=%d inventory=%+v", calls, got.Commitments.FinalReleaseArtifacts) + } +} + func TestRequiredPublicArtifactsV4ReleaseReviewOmitsHistoricalReplayPayloads(t *testing.T) { pair := func(base string) SignedArtifactRefs { return SignedArtifactRefs{Record: inspectionTestRef(base + ".json"), Signature: inspectionTestRef(base + ".sig")} diff --git a/release/release-notes.md b/release/release-notes.md index 8d17dad..b6d12e8 100644 --- a/release/release-notes.md +++ b/release/release-notes.md @@ -1,5 +1,15 @@ ## What changed +- Fixed storage-first final signing to pass Proof-tool the authenticated current + release-review checkpoint, rather than the separately signed operational + evidence bundle. Proof-tool correctly rejected the bundle when it was used as + a checkpoint, so affected ceremonies stopped safely before creating or + publishing a release package. The retained ceremony can resume from its + frozen review without repeating either contribution phase. +- Pinned the corrected protected-main Proof-tool release so a new empty client + receives the signed final bootstrap, derives the complete closed release + inventory, and can reconstruct the published final ceremony without files + retained from an earlier role workspace. - Added a narrowly scoped recovery command for the affected 1828 coordinator release when its first Phase 1 publication was interrupted by expired AWS session credentials. The command accepts only the retained authenticated @@ -69,17 +79,18 @@ schema-dispatched workflow. ## Validation status and current limits -- Repository tests, vet, launcher tests, the unsigned rehearsal build, and the - existing full-ceremony/archive-replay CI passed before the final proof-tool - pin update. The pinned proof-tool release assets and GitHub provenance were - verified against its exact protected-main commit. -- A live Cloudflare R2 rehearsal with one participant in each phase completed - enrollment, all of Phase 1, and entered Phase 2 before the merge decision. - An earlier run reached coordinator replay and creation of the final release - candidate, but the test process exceeded its 30-minute harness timeout while - publishing that checkpoint. A terminal live final-release reconstruction was - not yet recorded at merge time. -- A complete live Amazon S3 rehearsal has not yet been run for this version. +- Repository tests, vet, and launcher tests pass. The pinned Proof-tool release + assets and GitHub provenance were verified against its exact protected-main + commit. +- A live Cloudflare R2 rehearsal with the minimal required roles completed both + contribution phases, future-beacon transitions, coordinator mathematical + replay, release signing, final checkpoint publication, and a second fresh + empty-workspace reconstruction from stored bytes. The recovery check was run + again after the Docker artifact-root containment fix. +- Live Amazon S3 initial publication/reconstruction and scoped temporary-grant + tests pass, including version-pinned reads and rejection outside the granted + prefix. A complete S3 contribution-to-release rehearsal has not yet been run + for this version. - The storage-first guided path in this release supports the minimal required roles: coordinator, participant, and release signer. Enabled witness, mirror, or auditor journeys are deferred; setup refuses those nonzero assurance @@ -93,6 +104,13 @@ schema-dispatched workflow. ## Tessera compatibility +The release-signing fix changes no setup contract, ceremony format, storage +object, or Tessera request. This release pins a corrected Proof-tool runtime +for new storage-first ceremonies. Existing frozen storage-first ceremonies +retain their exact runtime and signed state and can resume final signing with +the compatibility path in this launcher. No Tessera change is required for +this fix. + The recovery command does not change setup contracts, signed definitions, ceremony data, proof-tool pins, Tessera fields or selected releases. It is an explicit compatibility bridge for one frozen Relay release and does not make diff --git a/release/role-images.json b/release/role-images.json index 3b21493..b3c64fb 100644 --- a/release/role-images.json +++ b/release/role-images.json @@ -3,12 +3,12 @@ "aws_cli_image": "public.ecr.aws/aws-cli/aws-cli@sha256:b6aeb95d19d7f5a8cae4eb814cb16739b6b2a4f2f46f427ada6a8c9a20d9881d", "mpc": { "linux_amd64": { - "url": "https://github.com/zksecurity/proof-tool/releases/download/mpc-ci-0a6ec7f39527df06b1aebf8c1a60ff3a75759e89/mpc-ceremony", - "sha256": "db23d1ff01b714b7f3724747cb00567bdedbf87cc6ad88bb0efcba28e715764f" + "url": "https://github.com/zksecurity/proof-tool/releases/download/mpc-ci-9333ad0b995ac90017f3b419c9a9073730336f7b/mpc-ceremony", + "sha256": "54ff341548c8519c3779500fa4ce425ab22d75ecdfc02ada014a87208da1f1cb" }, "linux_arm64": { - "url": "https://github.com/zksecurity/proof-tool/releases/download/mpc-ci-0a6ec7f39527df06b1aebf8c1a60ff3a75759e89/mpc-ceremony-linux-arm64", - "sha256": "af3a7ff5ccd04ad51917e676442de08bf5ace6d19c97295614e9c54f9fc12b11" + "url": "https://github.com/zksecurity/proof-tool/releases/download/mpc-ci-9333ad0b995ac90017f3b419c9a9073730336f7b/mpc-ceremony-linux-arm64", + "sha256": "c870ba28f2bcffcf9a67b9aa6c9754fc6505b7afe1cd7704d115c14b6f38014b" } } } diff --git a/scripts/storage-setup/setup-aws.sh b/scripts/storage-setup/setup-aws.sh index 136388c..a5590c9 100755 --- a/scripts/storage-setup/setup-aws.sh +++ b/scripts/storage-setup/setup-aws.sh @@ -436,7 +436,7 @@ jq -n --arg bucket "$INBOX_BUCKET" '{ Version: "2012-10-17", Statement: [{ Effect: "Allow", - Action: ["s3:PutObject", "s3:GetObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"], + Action: ["s3:PutObject", "s3:GetObject", "s3:GetObjectVersion", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"], Resource: ("arn:aws:s3:::" + $bucket + "/*") }] }' >"$work_dir/grant-policy.json"