diff --git a/.github/workflows/publish-role-images.yml b/.github/workflows/publish-role-images.yml index 4d4bbb5..3b59f66 100644 --- a/.github/workflows/publish-role-images.yml +++ b/.github/workflows/publish-role-images.yml @@ -271,11 +271,12 @@ jobs: run: | set -euo pipefail chmod +x launchers/relay-linux-amd64 - launchers/relay-linux-amd64 tessera release-manifest --role-images relay-role-images.release.json --out ceremony-software-manifest-v2.json + launchers/relay-linux-amd64 tessera release-manifest-v3 --role-images relay-role-images.release.json --out ceremony-software-manifest-v3.json - name: Attest setup software manifest uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 with: - subject-path: ceremony-software-manifest-v2.json + subject-path: | + ceremony-software-manifest-v3.json - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ env.RELEASE_TAG }} @@ -284,5 +285,5 @@ jobs: body_path: release/release-notes.md files: | relay-role-images.release.json - ceremony-software-manifest-v2.json + ceremony-software-manifest-v3.json launchers/* diff --git a/AGENTS.md b/AGENTS.md index 1dd84e4..11169c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,9 +103,11 @@ acceptable release pin. Before merging Relay, update `release/release-notes.md`, run the normal Go, shell, launcher, release, and ceremony checks, and report the exact PR head to Tessera's compatibility workflow. Relay pull requests use a 12-second, -explicitly non-production witness window while still retrieving two real future +explicitly non-production beacon lead while still retrieving two real future Quicknet rounds. Protected-main and daily scheduled checks use Tessera's -180-second rehearsal window. Production's 24-hour minimum is unchanged. Keep +180-second rehearsal lead. Production defaults to 24 hours, but the exact lead +is signed ceremony policy; shorter production settings require a prominent +warning and explicit coordinator review. Keep the real end-to-end path through contributions, cleanup, both beacons, audit, release signing, archive packing, and public replay; it detects incompatibilities that isolated repository tests can miss. diff --git a/cmd/relay/access_commands.go b/cmd/relay/access_commands.go index ee22108..88e3b3c 100644 --- a/cmd/relay/access_commands.go +++ b/cmd/relay/access_commands.go @@ -22,6 +22,7 @@ import ( "time" "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/storagefirst" "github.com/zksecurity/relay/internal/store" "github.com/zksecurity/relay/internal/transcript" ) @@ -214,6 +215,8 @@ func isAccessDenied(err error) bool { func runGrant(args []string) error { set := flag.NewFlagSet("coordinator grant", flag.ContinueOnError) var storagePath, role, identity, ttlText, minimumText, legacyMinimumText, enrollment, enrollmentSignature, out string + var checkpointDigest, submissionKind, phase, attemptID string + var index uint set.StringVar(&storagePath, "storage", "", "storage configuration from configure-storage") set.StringVar(&role, "role", "", "participant, witness, mirror, auditor, release, decision") set.StringVar(&identity, "identity", "", "authenticated ceremony or enrollment identity") @@ -223,6 +226,11 @@ func runGrant(args []string) error { set.StringVar(&enrollment, "enrollment", "", "signed operational enrollment for roles other than participant") set.StringVar(&enrollmentSignature, "enrollment-signature", "", "detached enrollment signature") set.StringVar(&out, "out", "", "fresh secret grant file") + set.StringVar(&checkpointDigest, "checkpoint-digest", "", "authenticated V4 allocation checkpoint digest") + set.StringVar(&submissionKind, "submission-kind", "", "V4 submission kind") + set.StringVar(&phase, "phase", "", "V4 ceremony phase") + set.UintVar(&index, "index", 0, "V4 one-based contribution index") + set.StringVar(&attemptID, "attempt-id", "", "V4 allocated attempt ID") if err := set.Parse(args); err != nil { return err } @@ -235,6 +243,10 @@ func runGrant(args []string) error { if storagePath == "" || role == "" || identity == "" || ttlText == "" || minimumText == "" || out == "" { return errors.New("--storage, --role, --identity, --credential-ttl, --minimum-remaining and --out are required") } + v4 := checkpointDigest != "" || submissionKind != "" || phase != "" || index != 0 || attemptID != "" + if v4 && (checkpointDigest == "" || submissionKind == "" || phase == "" || index == 0 || index > 255 || attemptID == "") { + return errors.New("V4 grants require --checkpoint-digest, --submission-kind, --phase, --index and --attempt-id together") + } config, err := loadStorageConfig(storagePath) if err != nil { return err @@ -247,18 +259,81 @@ func runGrant(args []string) error { if err != nil || minimum <= 0 || minimum > ttl { return errors.New("--minimum-remaining must be positive and no greater than --credential-ttl") } - prefix, err := access.Prefix(config.CeremonyID, role, identity) + var prefix string + if v4 { + if submissionKind == access.SubmissionKindCandidate && role != access.RoleParticipant { + return errors.New("V4 candidate grants support only participants") + } + if submissionKind == access.SubmissionKindRelease && role != access.RoleRelease { + return errors.New("V4 release grants support only the release signer") + } + if submissionKind != access.SubmissionKindCandidate && submissionKind != access.SubmissionKindEnrollment && submissionKind != access.SubmissionKindRelease { + return errors.New("V4 grants support candidate, enrollment or release uploads") + } + prefix, err = (storagefirst.DeliveryScope{CeremonyID: config.CeremonyID, AttemptID: attemptID, Kind: submissionKind}).Prefix() + } else { + prefix, err = access.Prefix(config.CeremonyID, role, identity) + } if err != nil { return err } - if err := authenticateGrantIdentity(config, role, identity, enrollment, enrollmentSignature); err != nil { - return err + if v4 && submissionKind == access.SubmissionKindEnrollment { + if err := authenticateEnrollmentGrantAssignment(config, role, identity, int(index)); err != nil { + return err + } + } else { + if err := authenticateGrantIdentity(config, role, identity, enrollment, enrollmentSignature); err != nil { + return err + } } now := time.Now().UTC().Truncate(time.Second) + requestID := "" + if v4 { + requestID, err = randomID() + if err != nil { + return err + } + // Validate every non-secret field and prove the destination directory is + // writable before asking R2/AWS to mint a live credential. A failed + // local validation must never leave an unseen cloud grant behind. + preflight := access.StorageFirstGrant{ + Schema: access.GrantSchemaV2, Provider: config.Provider, CeremonyID: config.CeremonyID, + GrantRequestID: requestID, CheckpointDigest: checkpointDigest, SubmissionKind: submissionKind, + Phase: phase, Index: uint8(index), IdentityID: identity, AttemptID: attemptID, + Endpoint: config.Endpoint, Region: config.Region, InboxBucket: config.InboxBucket, + Prefix: prefix + "/", ManifestKey: prefix + "/manifest.json", + IssuedAt: now.Format(time.RFC3339), ExpiresAt: now.Add(ttl).Format(time.RFC3339), + Credentials: access.SessionCredentials{AccessKeyID: "preflight", SecretAccessKey: "preflight", SessionToken: "preflight"}, + } + if err := preflight.Validate(); err != nil { + return err + } + if err := preflightFreshGrantOutput(out); err != nil { + return err + } + } credentials, expires, err := issueCredentials(config, identity, prefix, ttl, now) if err != nil { return err } + if v4 { + grant := access.StorageFirstGrant{ + Schema: access.GrantSchemaV2, Provider: config.Provider, CeremonyID: config.CeremonyID, + GrantRequestID: requestID, CheckpointDigest: checkpointDigest, SubmissionKind: submissionKind, + Phase: phase, Index: uint8(index), IdentityID: identity, AttemptID: attemptID, + Endpoint: config.Endpoint, Region: config.Region, InboxBucket: config.InboxBucket, + Prefix: prefix + "/", ManifestKey: prefix + "/manifest.json", + IssuedAt: now.Format(time.RFC3339), ExpiresAt: expires.UTC().Format(time.RFC3339), Credentials: credentials, + } + if err := grant.Validate(); err != nil { + return err + } + if err := writeJSONNoReplace(out, grant, 0o600); err != nil { + return err + } + fmt.Printf("issued %s upload grant for %s\nprefix: %s\nexpires: %s\n", submissionKind, identity, grant.Prefix, grant.ExpiresAt) + return nil + } grant := access.Grant{ Schema: access.GrantSchema, Provider: config.Provider, CeremonyID: config.CeremonyID, Role: role, IdentityID: identity, Endpoint: config.Endpoint, Region: config.Region, @@ -276,6 +351,77 @@ func runGrant(args []string) error { return nil } +func authenticateEnrollmentGrantAssignment(config access.StorageConfig, role, identity string, index int) error { + inspector := transcript.Inspector{ + Executable: config.CeremonyBinary, CeremonyPath: config.CeremonyPath, + CeremonySignaturePath: config.CeremonySignature, CoordinatorPublicKeyPath: config.CoordinatorPublicKey, + } + definition, err := inspector.Definition() + if err != nil { + return err + } + if definition.CeremonyID != config.CeremonyID { + return errors.New("authenticated definition does not match the storage ceremony") + } + want := "participant" + if role != access.RoleParticipant { + var ok bool + want, ok = ceremonyEnrollmentRole(role) + if !ok { + return errors.New("role cannot receive a formal enrollment grant") + } + } + journey, err := definition.RequireJourney() + if err != nil { + return err + } + for _, expected := range journey.RequiredEnrollments { + if expected.Identity.ID == identity && expected.Role == want && expected.RoleIndex == index { + return nil + } + } + return errors.New("identity, role and index are not an exact signed enrollment assignment") +} + +func preflightFreshGrantOutput(path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return errors.New("V4 grant output must be an absolute clean path") + } + if _, err := os.Lstat(path); err == nil { + return errors.New("V4 grant output already exists") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + parent := filepath.Dir(path) + if err := ensurePrivateDirectory(parent); err != nil { + return fmt.Errorf("prepare protected grant directory: %w", err) + } + probe, err := os.CreateTemp(parent, ".relay-grant-preflight-") + if err != nil { + return fmt.Errorf("test protected grant output: %w", err) + } + name := probe.Name() + if err := probe.Chmod(0600); err != nil { + _ = probe.Close() + _ = os.Remove(name) + return err + } + closeErr := probe.Close() + removeErr := os.Remove(name) + if closeErr != nil || removeErr != nil { + return errors.Join(closeErr, removeErr) + } + return syncDirectory(parent) +} + +func loadStorageFirstGrant(path string) (access.StorageFirstGrant, error) { + raw, err := readProtectedCredentialBytes(path, 1<<20) + if err != nil { + return access.StorageFirstGrant{}, err + } + return access.Decode(raw, access.StorageFirstGrant.Validate) +} + func authenticateGrantIdentity(config access.StorageConfig, role, identity, enrollment, enrollmentSignature string) error { inspector := transcript.Inspector{ Executable: config.CeremonyBinary, CeremonyPath: config.CeremonyPath, @@ -531,6 +677,12 @@ func grantClient(grant access.Grant) store.Client { return store.Client{Endpoint: grant.Endpoint, Region: grant.Region, Bucket: grant.InboxBucket, Credentials: &credentials} } +func storageFirstGrantClient(grant access.StorageFirstGrant) store.Client { + credentials := store.Credentials{AccessKeyID: grant.Credentials.AccessKeyID, + SecretAccessKey: grant.Credentials.SecretAccessKey, SessionToken: grant.Credentials.SessionToken} + return store.Client{Endpoint: grant.Endpoint, Region: grant.Region, Bucket: grant.InboxBucket, Credentials: &credentials} +} + func loadStorageConfig(path string) (access.StorageConfig, error) { raw, err := os.ReadFile(path) if err != nil { diff --git a/cmd/relay/coordinator_candidate_v4.go b/cmd/relay/coordinator_candidate_v4.go new file mode 100644 index 0000000..68adfa6 --- /dev/null +++ b/cmd/relay/coordinator_candidate_v4.go @@ -0,0 +1,236 @@ +package main + +import ( + "crypto/sha256" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +const workflowV4CandidateFetchReceiptSchema = "relay-workflow-v4-candidate-fetch-v1" + +// workflowV4CandidateFetchReceipt remains beside, never inside, the fixed +// protocol candidate directory. The proof-tool rejection command requires +// that directory to contain exactly its five protocol files. +type workflowV4CandidateFetchReceipt struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + AttemptID string `json:"attempt_id"` + Kind string `json:"kind"` + Manifest state.ContentRef `json:"manifest"` + Files []state.ContentRef `json:"files"` +} + +// runCoordinatorFetchCandidateV4 downloads one allocated transport attempt +// through coordinator-authenticated inbox access. It does not accept the +// candidate; proof-tool later replays and verifies it in the signing container. +func runCoordinatorFetchCandidateV4(args []string) error { + set := flag.NewFlagSet("coordinator fetch-candidate-v4", flag.ContinueOnError) + var storagePath, root, checkpoint, signature, attempt, out, ceremony, ceremonySignature, coordinatorKey, ceremonyBinary string + set.StringVar(&storagePath, "storage", "", "verified public storage configuration") + set.StringVar(&root, "artifact-root", "", "complete retained public artifact root") + set.StringVar(&checkpoint, "checkpoint", "", "current signed checkpoint") + set.StringVar(&signature, "checkpoint-signature", "", "current detached checkpoint signature") + set.StringVar(&attempt, "attempt-id", "", "active candidate attempt") + set.StringVar(&out, "out-dir", "", "fresh private candidate directory") + set.StringVar(&ceremony, "ceremony", "", "signed ceremony definition") + set.StringVar(&ceremonySignature, "ceremony-signature", "", "definition signature") + set.StringVar(&coordinatorKey, "coordinator-key", "", "independently authenticated coordinator public key") + set.StringVar(&ceremonyBinary, "ceremony-binary", "mpc-ceremony", "approved proof-tool executable") + if err := set.Parse(args); err != nil { + return err + } + for name, value := range map[string]string{"--storage": storagePath, "--artifact-root": root, "--checkpoint": checkpoint, "--checkpoint-signature": signature, "--out-dir": out, "--ceremony": ceremony, "--ceremony-signature": ceremonySignature, "--coordinator-key": coordinatorKey} { + if value == "" || !filepath.IsAbs(value) || filepath.Clean(value) != value { + return fmt.Errorf("%s requires an absolute clean path", name) + } + } + if attempt == "" { + return errors.New("--attempt-id is required") + } + config, err := loadStorageConfig(storagePath) + if err != nil { + return err + } + if config.CeremonyPath != ceremony || config.CeremonySignature != ceremonySignature || config.CoordinatorPublicKey != coordinatorKey { + return errors.New("storage configuration and exact authenticated ceremony paths differ") + } + if _, err := publicationNameV4(root, checkpoint); err != nil { + return fmt.Errorf("checkpoint path: %w", err) + } + if _, err := publicationNameV4(root, signature); err != nil { + return fmt.Errorf("checkpoint signature path: %w", err) + } + inspector := transcript.Inspector{Executable: ceremonyBinary, CeremonyPath: ceremony, CeremonySignaturePath: ceremonySignature, CoordinatorPublicKeyPath: coordinatorKey, TranscriptRoot: root} + inspection, err := inspector.StoredCheckpointV4(root, checkpoint, signature) + if err != nil { + return err + } + if inspection.Checkpoint.CeremonyID != config.CeremonyID { + return errors.New("checkpoint and storage configuration belong to different ceremonies") + } + active := false + for _, slot := range inspection.Checkpoint.Deliveries { + if slot.AttemptID == attempt && slot.Kind == "candidate" && slot.Status == "allocated" { + active = true + } + } + if !active { + return errors.New("attempt is not an active candidate allocation in the authenticated checkpoint") + } + if _, err := os.Lstat(out); !errors.Is(err, os.ErrNotExist) { + return errors.New("candidate output already exists; inspect and verify it instead of downloading over it") + } + if err := os.MkdirAll(filepath.Dir(out), 0o700); err != nil { + return err + } + objects := coordinatorClient(config, config.InboxBucket) + fetched, err := storagefirst.FetchDeliveryVerified(objects, storagefirst.DeliveryScope{CeremonyID: config.CeremonyID, AttemptID: attempt, Kind: "candidate"}, workflowV4CandidateDeliveryInventory(), filepath.Dir(out)) + if err != nil { + return err + } + if err := os.Rename(fetched.Dir, out); err != nil { + _ = os.RemoveAll(fetched.Dir) + return err + } + if err := syncDirectory(filepath.Dir(out)); err != nil { + return err + } + receipt := workflowV4CandidateFetchReceipt{Schema: workflowV4CandidateFetchReceiptSchema, CeremonyID: config.CeremonyID, AttemptID: attempt, Kind: "candidate", Manifest: fetched.Manifest, Files: fetched.Files} + if err := writeJSONNoReplace(workflowV4CandidateFetchReceiptPath(out), receipt, 0o600); err != nil { + return fmt.Errorf("record transport-checked candidate receipt: %w", err) + } + fmt.Printf("Downloaded and transport-checked the fixed five-file candidate to %s. It is not accepted; proof-tool must replay it.\n", out) + return nil +} + +func workflowV4CandidateFetchReceiptPath(candidateDir string) string { + return candidateDir + ".fetch.json" +} + +// quarantineWorkflowV4CandidateDownload preserves an incomplete or changed +// local download before a fresh fetch. It creates a new retained directory and +// syncs both sides of each move, so it never overwrites prior local evidence. +// The new fetch still has to pass the same authenticated manifest and fixed +// five-file payload checks. +func quarantineWorkflowV4CandidateDownload(candidateDir string) (string, error) { + parent := filepath.Dir(candidateDir) + retainedRoot := filepath.Join(parent, "retained") + if err := os.MkdirAll(retainedRoot, 0o700); err != nil { + return "", err + } + if err := syncDirectory(parent); err != nil { + return "", err + } + retained, err := os.MkdirTemp(retainedRoot, filepath.Base(candidateDir)+"-") + if err != nil { + return "", err + } + if err := syncDirectory(retainedRoot); err != nil { + return "", err + } + move := func(source, destination string) error { + if _, err := os.Lstat(destination); !errors.Is(err, os.ErrNotExist) { + if err == nil { + return errors.New("retained recovery destination already exists") + } + return err + } + if err := os.Rename(source, destination); err != nil { + return err + } + if err := syncDirectory(filepath.Dir(source)); err != nil { + return err + } + return syncDirectory(filepath.Dir(destination)) + } + receipt := workflowV4CandidateFetchReceiptPath(candidateDir) + if _, err := os.Lstat(receipt); err == nil { + if err := move(receipt, filepath.Join(retained, "transport-receipt.json")); err != nil { + return "", fmt.Errorf("preserve candidate transport receipt: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + if err := move(candidateDir, filepath.Join(retained, "candidate")); err != nil { + return "", fmt.Errorf("preserve candidate download: %w", err) + } + return retained, nil +} + +// validateWorkflowV4CandidateFetchReceipt proves that the retained directory +// is still the exact five-file package previously returned by FetchDelivery. +// It does not authenticate the contribution: proof-tool does that separately. +func validateWorkflowV4CandidateFetchReceipt(candidateDir string, scope transcript.ContributionScopeV4, attempt string) error { + var receipt workflowV4CandidateFetchReceipt + if err := readWorkflowV4JSON(workflowV4CandidateFetchReceiptPath(candidateDir), &receipt); err != nil { + return fmt.Errorf("read candidate transport receipt: %w", err) + } + if receipt.Schema != workflowV4CandidateFetchReceiptSchema || receipt.CeremonyID != scope.CeremonyID || receipt.AttemptID != attempt || receipt.Kind != "candidate" || receipt.Manifest.Name != "manifest.json" || !validCoordinatorCommitDigest(receipt.Manifest.SHA256) || receipt.Manifest.Size <= 0 { + return errors.New("candidate transport receipt does not match the active signed allocation") + } + expected := workflowV4CandidateDeliveryInventory() + if len(receipt.Files) != len(expected) { + return errors.New("candidate transport receipt has an unexpected file set") + } + refs := make(map[string]state.ContentRef, len(receipt.Files)) + for _, ref := range receipt.Files { + if ref.Name == "" || ref.Size <= 0 || ref.Size > expected[ref.Name] || !validCoordinatorCommitDigest(ref.SHA256) { + return errors.New("candidate transport receipt has an invalid file reference") + } + if _, duplicate := refs[ref.Name]; duplicate { + return errors.New("candidate transport receipt repeats a file") + } + refs[ref.Name] = ref + } + entries, err := os.ReadDir(candidateDir) + if err != nil { + return err + } + if len(entries) != len(expected) { + return errors.New("retained candidate directory has an unexpected file set") + } + for name := range expected { + ref, ok := refs[name] + if !ok { + return errors.New("candidate transport receipt omits a required file") + } + if err := verifyWorkflowV4CandidateReceiptFile(filepath.Join(candidateDir, name), ref); err != nil { + return fmt.Errorf("revalidate transport-checked candidate %s: %w", name, err) + } + } + return nil +} + +func verifyWorkflowV4CandidateReceiptFile(path string, ref state.ContentRef) error { + before, err := os.Lstat(path) + if err != nil || !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 || before.Size() != ref.Size { + return errors.New("candidate file is not the expected regular file") + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + opened, err := file.Stat() + if err != nil || !opened.Mode().IsRegular() || !os.SameFile(before, opened) || opened.Size() != before.Size() { + return errors.New("candidate file changed while opening") + } + hash := sha256.New() + n, err := io.Copy(hash, io.LimitReader(file, before.Size()+1)) + if err != nil { + return err + } + after, err := file.Stat() + if err != nil || n != before.Size() || after.Size() != before.Size() || !after.ModTime().Equal(before.ModTime()) || "sha256:"+fmt.Sprintf("%x", hash.Sum(nil)) != ref.SHA256 { + return errors.New("candidate file changed while hashing") + } + return nil +} diff --git a/cmd/relay/coordinator_commit_journal.go b/cmd/relay/coordinator_commit_journal.go new file mode 100644 index 0000000..b240f69 --- /dev/null +++ b/cmd/relay/coordinator_commit_journal.go @@ -0,0 +1,745 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "unicode/utf8" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/store" +) + +const coordinatorCommitJournalSchema = "relay-coordinator-commit-journal-v2" + +type coordinatorCommitStage string + +const ( + commitInnerSigningIntent coordinatorCommitStage = "inner-signing-intent" + commitInnerSigned coordinatorCommitStage = "inner-signed" + commitCheckpointSigningIntent coordinatorCommitStage = "checkpoint-signing-intent" + commitCheckpointSigned coordinatorCommitStage = "checkpoint-signed" + commitChildAuthenticated coordinatorCommitStage = "child-authenticated" + commitRootCASIntent coordinatorCommitStage = "root-cas-intent" + commitRootCASCommitted coordinatorCommitStage = "root-cas-committed" +) + +// coordinatorCommitPlan is immutable for the lifetime of one commit. The +// connected coordinator must durably create this plan before asking the inner +// record signer to run. Object-store keys are recorded separately from local +// output paths so a resumed commit cannot redirect either one. +type coordinatorCommitPlan struct { + OperationID string `json:"operation_id"` + CeremonyID string `json:"ceremony_id"` + PreviousRoot *state.Root `json:"previous_root,omitempty"` + PreviousRootVersion *store.ObjectVersion `json:"previous_root_version,omitempty"` + InnerOutputPaths []string `json:"inner_output_paths"` +} + +// coordinatorCheckpointSigningIntent fixes the local and remote destinations +// before checkpoint signing. It is separate from the initial plan because a +// content-addressed checkpoint path may only be known after inner outputs are +// complete and hashed. +type coordinatorCheckpointSigningIntent struct { + CheckpointOutputPath string `json:"checkpoint_output_path"` + CheckpointSignatureOutputPath string `json:"checkpoint_signature_output_path"` + TargetCheckpointPath string `json:"target_checkpoint_path"` + TargetCheckpointSignaturePath string `json:"target_checkpoint_signature_path"` +} + +type coordinatorRootCASIntent struct { + RootPayloadOutputPath string `json:"root_payload_output_path"` + TargetRootPath string `json:"target_root_path"` +} + +// coordinatorCommitOutput binds one local output path to the exact bytes that +// were produced there. Digests are supplied only after the caller hashes the +// completed regular file. +type coordinatorCommitOutput struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` +} + +// coordinatorAuthenticatedChild records the exact local inputs and immutable +// references that were successfully authenticated before root publication. +// A resumed caller must run AuthenticateRootChild again with these inputs; the +// recorded evidence result is a recovery boundary, not a substitute for proof +// verification. +type coordinatorAuthenticatedChild struct { + ArtifactRoot string `json:"artifact_root"` + Checkpoint state.ContentRef `json:"checkpoint"` + CheckpointSignature state.ContentRef `json:"checkpoint_signature"` + Evidence coordinatorAuthenticatedChildEvidence `json:"evidence"` +} + +type coordinatorAuthenticatedChildEvidence struct { + CeremonyID string `json:"ceremony_id"` + Sequence uint64 `json:"sequence"` + CheckpointDigest string `json:"checkpoint_digest"` + TransitionKind string `json:"transition_kind"` + FullyVerified bool `json:"fully_verified"` +} + +type coordinatorCommitJournalRecord struct { + Schema string `json:"schema"` + Stage coordinatorCommitStage `json:"stage"` + Plan coordinatorCommitPlan `json:"plan"` + InnerOutputs []coordinatorCommitOutput `json:"inner_outputs,omitempty"` + CheckpointIntent *coordinatorCheckpointSigningIntent `json:"checkpoint_intent,omitempty"` + Checkpoint *coordinatorCommitOutput `json:"checkpoint,omitempty"` + CheckpointSignature *coordinatorCommitOutput `json:"checkpoint_signature,omitempty"` + AuthenticatedChild *coordinatorAuthenticatedChild `json:"authenticated_child,omitempty"` + RootIntent *coordinatorRootCASIntent `json:"root_intent,omitempty"` + RootPayload *coordinatorCommitOutput `json:"root_payload,omitempty"` + CommittedRootVersion *store.ObjectVersion `json:"committed_root_version,omitempty"` +} + +// coordinatorCommitJournal has no cloud behavior. Callers hold the exclusive +// coordinator-workspace lock while using it and perform signing/uploads/CAS +// only after the corresponding intent method has returned successfully. +type coordinatorCommitJournal struct { + path string + record coordinatorCommitJournalRecord +} + +// openOrCreateCoordinatorCommitJournal creates the durable inner-signing +// intent, or resumes an existing journal only when the complete plan matches. +func openOrCreateCoordinatorCommitJournal(path string, plan coordinatorCommitPlan) (*coordinatorCommitJournal, error) { + if err := validateCoordinatorCommitJournalPath(path); err != nil { + return nil, err + } + if err := plan.validate(); err != nil { + return nil, err + } + plan = cloneCoordinatorCommitPlan(plan) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + + record, exists, err := readCoordinatorCommitJournal(path) + if err != nil { + return nil, err + } + if exists { + if !reflect.DeepEqual(record.Plan, plan) { + return nil, errors.New("existing coordinator commit journal does not exactly match the requested operation") + } + return &coordinatorCommitJournal{path: path, record: record}, nil + } + + record = coordinatorCommitJournalRecord{ + Schema: coordinatorCommitJournalSchema, + Stage: commitInnerSigningIntent, + Plan: plan, + } + journal := &coordinatorCommitJournal{path: path} + if err := journal.persist(record); err != nil { + return nil, err + } + return journal, nil +} + +func (j *coordinatorCommitJournal) stage() coordinatorCommitStage { return j.record.Stage } + +// recordInnerSigned records the exact inner signed outputs. It must complete +// before checkpointSigningIntent can persist the next signing boundary. +func (j *coordinatorCommitJournal) recordInnerSigned(outputs []coordinatorCommitOutput) error { + normalized, err := normalizeCommitOutputs(j.record.Plan.InnerOutputPaths, outputs) + if err != nil { + return err + } + if stageAtLeast(j.record.Stage, commitInnerSigned) { + if !reflect.DeepEqual(j.record.InnerOutputs, normalized) { + return errors.New("inner signing resume does not match the recorded output paths and digests") + } + return nil + } + if j.record.Stage != commitInnerSigningIntent { + return invalidCommitTransition(j.record.Stage, commitInnerSigned) + } + next := j.record + next.InnerOutputs = normalized + next.Stage = commitInnerSigned + return j.persist(next) +} + +// checkpointSigningIntent is the durable boundary immediately before asking +// the checkpoint signer to run. +func (j *coordinatorCommitJournal) checkpointSigningIntent(intent coordinatorCheckpointSigningIntent) error { + if err := intent.validate(); err != nil { + return err + } + if err := validateCommitDestinations(j.record.Plan, &intent, j.record.RootIntent); err != nil { + return err + } + if stageAtLeast(j.record.Stage, commitCheckpointSigningIntent) { + if j.record.CheckpointIntent == nil || *j.record.CheckpointIntent != intent { + return errors.New("checkpoint signing resume does not match the recorded intent") + } + return nil + } + if j.record.Stage != commitInnerSigned { + return invalidCommitTransition(j.record.Stage, commitCheckpointSigningIntent) + } + next := j.record + next.CheckpointIntent = &intent + next.Stage = commitCheckpointSigningIntent + return j.persist(next) +} + +func (j *coordinatorCommitJournal) recordCheckpointSigned(checkpoint, signature coordinatorCommitOutput) error { + if j.record.CheckpointIntent == nil { + return errors.New("checkpoint signing intent is missing") + } + checkpoint, err := normalizeCommitOutput(j.record.CheckpointIntent.CheckpointOutputPath, checkpoint) + if err != nil { + return fmt.Errorf("checkpoint output: %w", err) + } + signature, err = normalizeCommitOutput(j.record.CheckpointIntent.CheckpointSignatureOutputPath, signature) + if err != nil { + return fmt.Errorf("checkpoint signature output: %w", err) + } + if stageAtLeast(j.record.Stage, commitCheckpointSigned) { + if j.record.Checkpoint == nil || j.record.CheckpointSignature == nil || *j.record.Checkpoint != checkpoint || *j.record.CheckpointSignature != signature { + return errors.New("checkpoint signing resume does not match the recorded output paths and digests") + } + return nil + } + if j.record.Stage != commitCheckpointSigningIntent { + return invalidCommitTransition(j.record.Stage, commitCheckpointSigned) + } + next := j.record + next.Checkpoint = &checkpoint + next.CheckpointSignature = &signature + next.Stage = commitCheckpointSigned + return j.persist(next) +} + +// recordAuthenticatedChild persists the exact inputs and result of the full +// child-evidence verification. Root publication must not be attempted before +// this boundary is durable. Recovery re-runs authentication from these exact +// inputs before reconstructing CommitRoot. +func (j *coordinatorCommitJournal) recordAuthenticatedChild(child coordinatorAuthenticatedChild) error { + if err := child.validate(j.record.Plan, j.record.CheckpointIntent, j.record.Checkpoint, j.record.CheckpointSignature); err != nil { + return err + } + if stageAtLeast(j.record.Stage, commitChildAuthenticated) { + if j.record.AuthenticatedChild == nil || !reflect.DeepEqual(*j.record.AuthenticatedChild, child) { + return errors.New("authenticated child resume does not match the recorded references and evidence") + } + return nil + } + if j.record.Stage != commitCheckpointSigned { + return invalidCommitTransition(j.record.Stage, commitChildAuthenticated) + } + next := j.record + next.AuthenticatedChild = &child + next.Stage = commitChildAuthenticated + return j.persist(next) +} + +// rootCASIntent records the exact root payload before the caller attempts the +// compare-and-swap using Plan.PreviousRootVersion and intent.TargetRootPath. +func (j *coordinatorCommitJournal) rootCASIntent(intent coordinatorRootCASIntent, rootPayload coordinatorCommitOutput) error { + if err := intent.validate(); err != nil { + return err + } + if err := validateCommitDestinations(j.record.Plan, j.record.CheckpointIntent, &intent); err != nil { + return err + } + rootPayload, err := normalizeCommitOutput(intent.RootPayloadOutputPath, rootPayload) + if err != nil { + return fmt.Errorf("root payload output: %w", err) + } + if stageAtLeast(j.record.Stage, commitRootCASIntent) { + if j.record.RootIntent == nil || *j.record.RootIntent != intent || j.record.RootPayload == nil || *j.record.RootPayload != rootPayload { + return errors.New("root CAS resume does not match the recorded payload path and digest") + } + return nil + } + if j.record.Stage != commitChildAuthenticated { + return invalidCommitTransition(j.record.Stage, commitRootCASIntent) + } + next := j.record + next.RootIntent = &intent + next.RootPayload = &rootPayload + next.Stage = commitRootCASIntent + return j.persist(next) +} + +func (j *coordinatorCommitJournal) recordRootCASCommitted(committed store.ObjectVersion) error { + if err := validateCoordinatorRootVersion("committed root version", committed); err != nil { + return err + } + if j.record.Stage == commitRootCASCommitted { + if j.record.CommittedRootVersion == nil || *j.record.CommittedRootVersion != committed { + return errors.New("root CAS resume supplied a different committed object version") + } + return nil + } + if j.record.Stage != commitRootCASIntent { + return invalidCommitTransition(j.record.Stage, commitRootCASCommitted) + } + next := j.record + next.CommittedRootVersion = &committed + next.Stage = commitRootCASCommitted + return j.persist(next) +} + +func (j *coordinatorCommitJournal) persist(next coordinatorCommitJournalRecord) error { + if err := next.validate(); err != nil { + return err + } + if err := saveJSONAtomic(j.path, next); err != nil { + return err + } + j.record = next + return nil +} + +func readCoordinatorCommitJournal(path string) (coordinatorCommitJournalRecord, bool, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return coordinatorCommitJournalRecord{}, false, nil + } + if err != nil { + return coordinatorCommitJournalRecord{}, false, err + } + if len(raw) > 1<<20 { + return coordinatorCommitJournalRecord{}, false, errors.New("coordinator commit journal exceeds its size limit") + } + if err := rejectCommitJournalDuplicateFields(raw); err != nil { + return coordinatorCommitJournalRecord{}, false, fmt.Errorf("decode coordinator commit journal: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var record coordinatorCommitJournalRecord + if err := decoder.Decode(&record); err != nil { + return coordinatorCommitJournalRecord{}, false, fmt.Errorf("decode coordinator commit journal: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + err = errors.New("unexpected trailing JSON value") + } + return coordinatorCommitJournalRecord{}, false, fmt.Errorf("decode coordinator commit journal: %w", err) + } + if err := record.validate(); err != nil { + return coordinatorCommitJournalRecord{}, false, fmt.Errorf("validate coordinator commit journal: %w", err) + } + return record, true, nil +} + +func (p coordinatorCommitPlan) validate() error { + if !validFlowAttemptID(p.OperationID) { + return errors.New("coordinator commit operation ID must be 32 lowercase hexadecimal characters") + } + if !validCoordinatorCommitDigest(p.CeremonyID) { + return errors.New("coordinator commit ceremony ID must be a canonical SHA-256 digest") + } + if (p.PreviousRoot == nil) != (p.PreviousRootVersion == nil) { + return errors.New("previous root and previous root version must be supplied together") + } + if p.PreviousRoot != nil { + if err := p.PreviousRoot.Validate(); err != nil { + return fmt.Errorf("previous root: %w", err) + } + if p.PreviousRoot.CeremonyID != p.CeremonyID { + return errors.New("previous root belongs to another ceremony") + } + if err := validateCoordinatorRootVersion("previous root version", *p.PreviousRootVersion); err != nil { + return err + } + } + if len(p.InnerOutputPaths) == 0 || len(p.InnerOutputPaths) > 64 { + return errors.New("coordinator commit must declare between 1 and 64 inner output paths") + } + seen := make(map[string]struct{}, len(p.InnerOutputPaths)) + for _, path := range p.InnerOutputPaths { + if err := validateCommitLocalPath(path); err != nil { + return err + } + if _, exists := seen[path]; exists { + return fmt.Errorf("coordinator commit output path %q is repeated", path) + } + seen[path] = struct{}{} + } + return nil +} + +func cloneCoordinatorCommitPlan(plan coordinatorCommitPlan) coordinatorCommitPlan { + plan.InnerOutputPaths = append([]string(nil), plan.InnerOutputPaths...) + if plan.PreviousRoot != nil { + root := *plan.PreviousRoot + plan.PreviousRoot = &root + } + if plan.PreviousRootVersion != nil { + version := *plan.PreviousRootVersion + plan.PreviousRootVersion = &version + } + return plan +} + +func (c coordinatorAuthenticatedChild) validate(plan coordinatorCommitPlan, intent *coordinatorCheckpointSigningIntent, checkpoint, signature *coordinatorCommitOutput) error { + if intent == nil || checkpoint == nil || signature == nil { + return errors.New("checkpoint signing outputs are missing before child authentication") + } + if err := validateCommitLocalPath(c.ArtifactRoot); err != nil { + return fmt.Errorf("artifact root: %w", err) + } + testRoot := state.Root{ + Schema: state.RootSchema, CeremonyID: plan.CeremonyID, + Checkpoint: c.Checkpoint, CheckpointSignature: c.CheckpointSignature, + } + if err := testRoot.Validate(); err != nil { + return fmt.Errorf("authenticated child references: %w", err) + } + if c.Checkpoint.SHA256 != checkpoint.SHA256 || c.CheckpointSignature.SHA256 != signature.SHA256 { + return errors.New("authenticated child references do not match the exact signed checkpoint outputs") + } + if intent.TargetCheckpointPath != store.Key(c.Checkpoint.SHA256) || intent.TargetCheckpointSignaturePath != store.Key(c.CheckpointSignature.SHA256) { + return errors.New("checkpoint upload targets do not match the authenticated content-addressed child references") + } + evidence := c.Evidence + if !evidence.FullyVerified || evidence.CeremonyID != plan.CeremonyID || + evidence.CheckpointDigest != c.Checkpoint.SHA256 || evidence.TransitionKind == "" || + strings.TrimSpace(evidence.TransitionKind) != evidence.TransitionKind || strings.ContainsAny(evidence.TransitionKind, "\x00\r\n") { + return errors.New("authenticated child evidence does not exactly match the commit ceremony and checkpoint") + } + if plan.PreviousRoot == nil { + if evidence.Sequence != 0 { + return errors.New("initial commit child evidence must have sequence zero") + } + } else { + // The proof-tool-authenticated child is rechecked on resume; recording the + // expected direct sequence here catches an accidentally mixed operation. + if evidence.Sequence == 0 { + return errors.New("non-initial commit child evidence must advance the previous root") + } + } + return nil +} + +func (i coordinatorCheckpointSigningIntent) validate() error { + if err := validateCommitLocalPath(i.CheckpointOutputPath); err != nil { + return err + } + if err := validateCommitLocalPath(i.CheckpointSignatureOutputPath); err != nil { + return err + } + if i.CheckpointOutputPath == i.CheckpointSignatureOutputPath { + return errors.New("checkpoint output paths must be distinct") + } + if err := validateCommitObjectPath("target checkpoint path", i.TargetCheckpointPath); err != nil { + return err + } + if err := validateCommitObjectPath("target checkpoint signature path", i.TargetCheckpointSignaturePath); err != nil { + return err + } + if i.TargetCheckpointPath == i.TargetCheckpointSignaturePath { + return errors.New("checkpoint target paths must be distinct") + } + return nil +} + +func (i coordinatorRootCASIntent) validate() error { + if err := validateCommitLocalPath(i.RootPayloadOutputPath); err != nil { + return err + } + return validateCommitObjectPath("target root path", i.TargetRootPath) +} + +func (r coordinatorCommitJournalRecord) validate() error { + if r.Schema != coordinatorCommitJournalSchema { + return fmt.Errorf("journal schema %q, want %q", r.Schema, coordinatorCommitJournalSchema) + } + if err := r.Plan.validate(); err != nil { + return err + } + rank, ok := commitStageRank(r.Stage) + if !ok { + return fmt.Errorf("unknown coordinator commit stage %q", r.Stage) + } + if rank >= 1 { + if _, err := normalizeCommitOutputs(r.Plan.InnerOutputPaths, r.InnerOutputs); err != nil { + return fmt.Errorf("recorded inner outputs: %w", err) + } + } else if len(r.InnerOutputs) != 0 { + return errors.New("inner outputs were recorded before inner signing completed") + } + if rank >= 2 { + if r.CheckpointIntent == nil { + return errors.New("checkpoint signing intent is missing") + } + if err := r.CheckpointIntent.validate(); err != nil { + return err + } + } else if r.CheckpointIntent != nil { + return errors.New("checkpoint signing intent was recorded too early") + } + if rank >= 3 { + if r.Checkpoint == nil || r.CheckpointSignature == nil { + return errors.New("checkpoint outputs are missing after checkpoint signing") + } + if _, err := normalizeCommitOutput(r.CheckpointIntent.CheckpointOutputPath, *r.Checkpoint); err != nil { + return err + } + if _, err := normalizeCommitOutput(r.CheckpointIntent.CheckpointSignatureOutputPath, *r.CheckpointSignature); err != nil { + return err + } + } else if r.Checkpoint != nil || r.CheckpointSignature != nil { + return errors.New("checkpoint outputs were recorded before checkpoint signing completed") + } + if rank >= 4 { + if r.AuthenticatedChild == nil { + return errors.New("authenticated child is missing after evidence verification") + } + if err := r.AuthenticatedChild.validate(r.Plan, r.CheckpointIntent, r.Checkpoint, r.CheckpointSignature); err != nil { + return err + } + } else if r.AuthenticatedChild != nil { + return errors.New("authenticated child was recorded before checkpoint evidence verification") + } + if rank >= 5 { + if r.RootIntent == nil || r.RootPayload == nil { + return errors.New("root payload is missing after root CAS intent") + } + if err := r.RootIntent.validate(); err != nil { + return err + } + if _, err := normalizeCommitOutput(r.RootIntent.RootPayloadOutputPath, *r.RootPayload); err != nil { + return err + } + } else if r.RootIntent != nil || r.RootPayload != nil { + return errors.New("root payload was recorded before root CAS intent") + } + if err := validateCommitDestinations(r.Plan, r.CheckpointIntent, r.RootIntent); err != nil { + return err + } + if rank == 6 { + if r.CommittedRootVersion == nil { + return errors.New("committed root version is missing after root CAS completed") + } + if err := validateCoordinatorRootVersion("committed root version", *r.CommittedRootVersion); err != nil { + return err + } + } else if r.CommittedRootVersion != nil { + return errors.New("committed root version was recorded before the root CAS completed") + } + return nil +} + +func validateCommitDestinations(plan coordinatorCommitPlan, checkpoint *coordinatorCheckpointSigningIntent, root *coordinatorRootCASIntent) error { + local := make(map[string]struct{}, len(plan.InnerOutputPaths)+3) + for _, path := range plan.InnerOutputPaths { + local[path] = struct{}{} + } + if checkpoint != nil { + for _, path := range []string{checkpoint.CheckpointOutputPath, checkpoint.CheckpointSignatureOutputPath} { + if _, exists := local[path]; exists { + return fmt.Errorf("coordinator commit output path %q is repeated across intents", path) + } + local[path] = struct{}{} + } + } + if root != nil { + if _, exists := local[root.RootPayloadOutputPath]; exists { + return fmt.Errorf("coordinator commit output path %q is repeated across intents", root.RootPayloadOutputPath) + } + if checkpoint != nil && (root.TargetRootPath == checkpoint.TargetCheckpointPath || root.TargetRootPath == checkpoint.TargetCheckpointSignaturePath) { + return errors.New("root and checkpoint target paths must be distinct") + } + if root.TargetRootPath != state.RootKey(plan.CeremonyID) { + return errors.New("root target path does not match the exact ceremony discovery root") + } + } + return nil +} + +func normalizeCommitOutputs(expectedPaths []string, outputs []coordinatorCommitOutput) ([]coordinatorCommitOutput, error) { + if len(outputs) != len(expectedPaths) { + return nil, fmt.Errorf("got %d outputs, want %d", len(outputs), len(expectedPaths)) + } + byPath := make(map[string]coordinatorCommitOutput, len(outputs)) + for _, output := range outputs { + if _, exists := byPath[output.Path]; exists { + return nil, fmt.Errorf("output path %q is repeated", output.Path) + } + byPath[output.Path] = output + } + normalized := make([]coordinatorCommitOutput, 0, len(expectedPaths)) + for _, expected := range expectedPaths { + output, ok := byPath[expected] + if !ok { + return nil, fmt.Errorf("missing output for %q", expected) + } + checked, err := normalizeCommitOutput(expected, output) + if err != nil { + return nil, err + } + normalized = append(normalized, checked) + } + return normalized, nil +} + +func normalizeCommitOutput(expectedPath string, output coordinatorCommitOutput) (coordinatorCommitOutput, error) { + if output.Path != expectedPath { + return coordinatorCommitOutput{}, fmt.Errorf("output path %q, want %q", output.Path, expectedPath) + } + if !validCoordinatorCommitDigest(output.SHA256) { + return coordinatorCommitOutput{}, fmt.Errorf("output %q has a malformed SHA-256 digest", output.Path) + } + return output, nil +} + +func commitStageRank(stage coordinatorCommitStage) (int, bool) { + switch stage { + case commitInnerSigningIntent: + return 0, true + case commitInnerSigned: + return 1, true + case commitCheckpointSigningIntent: + return 2, true + case commitCheckpointSigned: + return 3, true + case commitChildAuthenticated: + return 4, true + case commitRootCASIntent: + return 5, true + case commitRootCASCommitted: + return 6, true + default: + return 0, false + } +} + +func stageAtLeast(current, expected coordinatorCommitStage) bool { + currentRank, currentOK := commitStageRank(current) + expectedRank, expectedOK := commitStageRank(expected) + return currentOK && expectedOK && currentRank >= expectedRank +} + +func invalidCommitTransition(current, target coordinatorCommitStage) error { + return fmt.Errorf("coordinator commit cannot move directly from %s to %s", current, target) +} + +func validateCoordinatorCommitJournalPath(path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return errors.New("coordinator commit journal path must be absolute and clean") + } + return nil +} + +func validateCommitLocalPath(path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("coordinator commit output path %q must be absolute and clean", path) + } + return nil +} + +func validateCommitObjectPath(label, path string) error { + if path == "" || filepath.IsAbs(path) || filepath.Clean(path) != path || path == "." || strings.HasPrefix(path, "../") || strings.Contains(path, "\\") { + return fmt.Errorf("%s %q must be a safe relative object path", label, path) + } + return nil +} + +func validCoordinatorCommitDigest(value string) bool { + if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 { + return false + } + for _, c := range strings.TrimPrefix(value, "sha256:") { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +func validateETag(label, value string) error { + if value == "" || len(value) > 1024 || !utf8.ValidString(value) { + return fmt.Errorf("%s is empty, too long, or invalid UTF-8", label) + } + for _, r := range value { + if r < 0x20 || r == 0x7f { + return fmt.Errorf("%s contains control characters", label) + } + } + return nil +} + +func validateCoordinatorRootVersion(label string, version store.ObjectVersion) error { + if err := validateETag(label+" ETag", version.ETag); err != nil { + return err + } + if len(version.VersionID) > 1024 || !utf8.ValidString(version.VersionID) || strings.ContainsAny(version.VersionID, "\x00\r\n") { + return fmt.Errorf("%s has an invalid version ID", label) + } + if version.Size <= 0 { + return fmt.Errorf("%s has a non-positive size", label) + } + return nil +} + +func rejectCommitJournalDuplicateFields(raw []byte) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + if err := inspectCommitJournalJSONValue(decoder); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("unexpected trailing JSON value") + } + return err + } + return nil +} + +func inspectCommitJournalJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delim, ok := token.(json.Delim) + if !ok { + return nil + } + switch delim { + case '{': + seen := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("JSON object key is not a string") + } + if _, exists := seen[key]; exists { + return fmt.Errorf("duplicate JSON field %q", key) + } + seen[key] = struct{}{} + if err := inspectCommitJournalJSONValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + return err + case '[': + for decoder.More() { + if err := inspectCommitJournalJSONValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + return err + default: + return errors.New("unexpected JSON delimiter") + } +} diff --git a/cmd/relay/coordinator_commit_journal_test.go b/cmd/relay/coordinator_commit_journal_test.go new file mode 100644 index 0000000..99c8174 --- /dev/null +++ b/cmd/relay/coordinator_commit_journal_test.go @@ -0,0 +1,482 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/store" +) + +func commitDigest(c string) string { return "sha256:" + strings.Repeat(c, 64) } + +func testCoordinatorCommitPlan(root string) coordinatorCommitPlan { + ceremonyID := commitDigest("1") + previousRoot := state.Root{ + Schema: state.RootSchema, CeremonyID: ceremonyID, + Checkpoint: state.ContentRef{Name: "checkpoints/0000.json", SHA256: commitDigest("2"), Size: 100}, + CheckpointSignature: state.ContentRef{Name: "checkpoints/0000.sig", SHA256: commitDigest("3"), Size: 64}, + } + previousVersion := store.ObjectVersion{ETag: `"parent-etag"`, VersionID: "parent-version", Size: 512} + return coordinatorCommitPlan{ + OperationID: strings.Repeat("1", 32), CeremonyID: ceremonyID, + PreviousRoot: &previousRoot, PreviousRootVersion: &previousVersion, + InnerOutputPaths: []string{filepath.Join(root, "chain.json"), filepath.Join(root, "chain.sig")}, + } +} + +func testCheckpointIntent(root string) coordinatorCheckpointSigningIntent { + return coordinatorCheckpointSigningIntent{ + CheckpointOutputPath: filepath.Join(root, "checkpoint.json"), + CheckpointSignatureOutputPath: filepath.Join(root, "checkpoint.sig"), + TargetCheckpointPath: store.Key(commitDigest("5")), + TargetCheckpointSignaturePath: store.Key(commitDigest("6")), + } +} + +func testAuthenticatedChild(root string, plan coordinatorCommitPlan) coordinatorAuthenticatedChild { + return coordinatorAuthenticatedChild{ + ArtifactRoot: root, + Checkpoint: state.ContentRef{Name: "checkpoints/0001.json", SHA256: commitDigest("5"), Size: 200}, + CheckpointSignature: state.ContentRef{Name: "checkpoints/0001.sig", SHA256: commitDigest("6"), Size: 64}, + Evidence: coordinatorAuthenticatedChildEvidence{ + CeremonyID: plan.CeremonyID, Sequence: 1, CheckpointDigest: commitDigest("5"), + TransitionKind: "phase1-outbound-published", FullyVerified: true, + }, + } +} + +func committedRootVersion() store.ObjectVersion { + return store.ObjectVersion{ETag: `"committed-etag"`, VersionID: "committed-version", Size: 512} +} + +func testRootIntent(root string) coordinatorRootCASIntent { + return coordinatorRootCASIntent{ + RootPayloadOutputPath: filepath.Join(root, "root.json"), + TargetRootPath: state.RootKey(commitDigest("1")), + } +} + +func commitOutput(path, c string) coordinatorCommitOutput { + return coordinatorCommitOutput{Path: path, SHA256: commitDigest(c)} +} + +func openTestCommitJournal(t *testing.T) (*coordinatorCommitJournal, coordinatorCommitPlan) { + t.Helper() + root := t.TempDir() + plan := testCoordinatorCommitPlan(root) + journal, err := openOrCreateCoordinatorCommitJournal(filepath.Join(root, "journal", "commit.json"), plan) + if err != nil { + t.Fatal(err) + } + return journal, plan +} + +func TestCoordinatorCommitJournalPersistsEveryIntentBoundary(t *testing.T) { + journal, plan := openTestCommitJournal(t) + checkpointIntent := testCheckpointIntent(filepath.Dir(plan.InnerOutputPaths[0])) + rootIntent := testRootIntent(filepath.Dir(plan.InnerOutputPaths[0])) + assertCommitJournalStage(t, journal.path, commitInnerSigningIntent) + + inner := []coordinatorCommitOutput{commitOutput(plan.InnerOutputPaths[1], "4"), commitOutput(plan.InnerOutputPaths[0], "3")} + if err := journal.recordInnerSigned(inner); err != nil { + t.Fatal(err) + } + assertCommitJournalStage(t, journal.path, commitInnerSigned) + if err := journal.checkpointSigningIntent(checkpointIntent); err != nil { + t.Fatal(err) + } + assertCommitJournalStage(t, journal.path, commitCheckpointSigningIntent) + checkpoint := commitOutput(checkpointIntent.CheckpointOutputPath, "5") + signature := commitOutput(checkpointIntent.CheckpointSignatureOutputPath, "6") + if err := journal.recordCheckpointSigned(checkpoint, signature); err != nil { + t.Fatal(err) + } + assertCommitJournalStage(t, journal.path, commitCheckpointSigned) + if err := journal.recordAuthenticatedChild(testAuthenticatedChild(filepath.Dir(plan.InnerOutputPaths[0]), plan)); err != nil { + t.Fatal(err) + } + assertCommitJournalStage(t, journal.path, commitChildAuthenticated) + rootPayload := commitOutput(rootIntent.RootPayloadOutputPath, "7") + if err := journal.rootCASIntent(rootIntent, rootPayload); err != nil { + t.Fatal(err) + } + assertCommitJournalStage(t, journal.path, commitRootCASIntent) + if err := journal.recordRootCASCommitted(committedRootVersion()); err != nil { + t.Fatal(err) + } + assertCommitJournalStage(t, journal.path, commitRootCASCommitted) + + reopened, err := openOrCreateCoordinatorCommitJournal(journal.path, plan) + if err != nil { + t.Fatal(err) + } + if reopened.stage() != commitRootCASCommitted || reopened.record.Plan.PreviousRootVersion == nil || reopened.record.Plan.PreviousRootVersion.VersionID != "parent-version" || reopened.record.AuthenticatedChild == nil || reopened.record.RootIntent == nil || reopened.record.RootIntent.TargetRootPath != state.RootKey(plan.CeremonyID) { + t.Fatalf("reopened journal lost exact commit state: %+v", reopened.record) + } + info, err := os.Stat(journal.path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("journal mode = %o", info.Mode().Perm()) + } +} + +func TestCoordinatorCommitJournalReloadsExactStateAtEveryCrashBoundary(t *testing.T) { + journal, plan := openTestCommitJournal(t) + checkpointIntent := testCheckpointIntent(filepath.Dir(plan.InnerOutputPaths[0])) + rootIntent := testRootIntent(filepath.Dir(plan.InnerOutputPaths[0])) + inner := []coordinatorCommitOutput{commitOutput(plan.InnerOutputPaths[0], "3"), commitOutput(plan.InnerOutputPaths[1], "4")} + checkpoint := commitOutput(checkpointIntent.CheckpointOutputPath, "5") + signature := commitOutput(checkpointIntent.CheckpointSignatureOutputPath, "6") + child := testAuthenticatedChild(filepath.Dir(plan.InnerOutputPaths[0]), plan) + rootPayload := commitOutput(rootIntent.RootPayloadOutputPath, "7") + committed := committedRootVersion() + + reopen := func(want coordinatorCommitStage) { + t.Helper() + var err error + journal, err = openOrCreateCoordinatorCommitJournal(journal.path, plan) + if err != nil { + t.Fatal(err) + } + if journal.stage() != want { + t.Fatalf("reloaded stage = %q, want %q", journal.stage(), want) + } + if journal.record.Plan.PreviousRoot == nil || journal.record.Plan.PreviousRootVersion == nil || + journal.record.Plan.PreviousRoot.Checkpoint.SHA256 != commitDigest("2") || + journal.record.Plan.PreviousRootVersion.VersionID != "parent-version" { + t.Fatalf("reloaded journal lost exact prior root: %+v", journal.record.Plan) + } + } + + reopen(commitInnerSigningIntent) + if err := journal.recordInnerSigned(inner); err != nil { + t.Fatal(err) + } + reopen(commitInnerSigned) + if err := journal.checkpointSigningIntent(checkpointIntent); err != nil { + t.Fatal(err) + } + reopen(commitCheckpointSigningIntent) + if err := journal.recordCheckpointSigned(checkpoint, signature); err != nil { + t.Fatal(err) + } + reopen(commitCheckpointSigned) + if err := journal.recordAuthenticatedChild(child); err != nil { + t.Fatal(err) + } + reopen(commitChildAuthenticated) + if journal.record.AuthenticatedChild.Checkpoint != child.Checkpoint || journal.record.AuthenticatedChild.Evidence != child.Evidence { + t.Fatal("reloaded journal lost authenticated child inputs") + } + if err := journal.rootCASIntent(rootIntent, rootPayload); err != nil { + t.Fatal(err) + } + reopen(commitRootCASIntent) + if err := journal.recordRootCASCommitted(committed); err != nil { + t.Fatal(err) + } + reopen(commitRootCASCommitted) + if journal.record.CommittedRootVersion == nil || *journal.record.CommittedRootVersion != committed { + t.Fatal("reloaded journal lost committed root version") + } +} + +func TestCoordinatorCommitJournalRequiresStrictForwardTransitions(t *testing.T) { + journal, plan := openTestCommitJournal(t) + checkpointIntent := testCheckpointIntent(filepath.Dir(plan.InnerOutputPaths[0])) + rootIntent := testRootIntent(filepath.Dir(plan.InnerOutputPaths[0])) + checkpoint := commitOutput(checkpointIntent.CheckpointOutputPath, "5") + signature := commitOutput(checkpointIntent.CheckpointSignatureOutputPath, "6") + rootPayload := commitOutput(rootIntent.RootPayloadOutputPath, "7") + + if err := journal.checkpointSigningIntent(checkpointIntent); err == nil { + t.Fatal("checkpoint intent skipped inner signing completion") + } + if err := journal.recordCheckpointSigned(checkpoint, signature); err == nil { + t.Fatal("checkpoint completion skipped its intent") + } + if err := journal.rootCASIntent(rootIntent, rootPayload); err == nil { + t.Fatal("root CAS intent skipped signed checkpoint") + } + if err := journal.recordAuthenticatedChild(testAuthenticatedChild(filepath.Dir(plan.InnerOutputPaths[0]), plan)); err == nil { + t.Fatal("child authentication skipped checkpoint completion") + } + if err := journal.recordRootCASCommitted(committedRootVersion()); err == nil { + t.Fatal("root CAS completion skipped its intent") + } + assertCommitJournalStage(t, journal.path, commitInnerSigningIntent) +} + +func TestCoordinatorCommitJournalRootIntentUsesExactCeremonyRootKey(t *testing.T) { + journal, plan := openTestCommitJournal(t) + checkpointIntent := testCheckpointIntent(filepath.Dir(plan.InnerOutputPaths[0])) + if err := journal.recordInnerSigned([]coordinatorCommitOutput{commitOutput(plan.InnerOutputPaths[0], "3"), commitOutput(plan.InnerOutputPaths[1], "4")}); err != nil { + t.Fatal(err) + } + if err := journal.checkpointSigningIntent(checkpointIntent); err != nil { + t.Fatal(err) + } + if err := journal.recordCheckpointSigned(commitOutput(checkpointIntent.CheckpointOutputPath, "5"), commitOutput(checkpointIntent.CheckpointSignatureOutputPath, "6")); err != nil { + t.Fatal(err) + } + if err := journal.recordAuthenticatedChild(testAuthenticatedChild(filepath.Dir(plan.InnerOutputPaths[0]), plan)); err != nil { + t.Fatal(err) + } + wrong := testRootIntent(filepath.Dir(plan.InnerOutputPaths[0])) + wrong.TargetRootPath = state.RootKey(commitDigest("9")) + if err := journal.rootCASIntent(wrong, commitOutput(wrong.RootPayloadOutputPath, "7")); err == nil { + t.Fatal("root intent for another ceremony key was accepted") + } + if journal.stage() != commitChildAuthenticated { + t.Fatalf("failed root intent advanced journal to %q", journal.stage()) + } +} + +func TestCoordinatorCommitJournalResumeMustMatchExactly(t *testing.T) { + journal, plan := openTestCommitJournal(t) + inner := []coordinatorCommitOutput{commitOutput(plan.InnerOutputPaths[0], "3"), commitOutput(plan.InnerOutputPaths[1], "4")} + if err := journal.recordInnerSigned(inner); err != nil { + t.Fatal(err) + } + if err := journal.recordInnerSigned([]coordinatorCommitOutput{inner[0], commitOutput(plan.InnerOutputPaths[1], "9")}); err == nil { + t.Fatal("changed inner output digest was accepted on resume") + } + if err := journal.recordInnerSigned([]coordinatorCommitOutput{inner[1], inner[0]}); err != nil { + t.Fatalf("equivalent reordered outputs should resume: %v", err) + } + checkpointIntent := testCheckpointIntent(filepath.Dir(plan.InnerOutputPaths[0])) + if err := journal.checkpointSigningIntent(checkpointIntent); err != nil { + t.Fatal(err) + } + changedCheckpointIntent := checkpointIntent + changedCheckpointIntent.TargetCheckpointPath = "checkpoints/other.json" + if err := journal.checkpointSigningIntent(changedCheckpointIntent); err == nil { + t.Fatal("resume with another checkpoint target was accepted") + } + + changed := cloneCoordinatorCommitPlan(plan) + changed.PreviousRootVersion.ETag = `"another-etag"` + if _, err := openOrCreateCoordinatorCommitJournal(journal.path, changed); err == nil { + t.Fatal("resume with another parent root ETag was accepted") + } + changed = plan + changed.InnerOutputPaths = append([]string(nil), plan.InnerOutputPaths...) + changed.InnerOutputPaths[0] = filepath.Join(filepath.Dir(plan.InnerOutputPaths[0]), "other-chain.json") + if _, err := openOrCreateCoordinatorCommitJournal(journal.path, changed); err == nil { + t.Fatal("resume with another output plan was accepted") + } +} + +func TestCoordinatorCommitJournalCopiesPlanAndRejectsOverlappingLaterPaths(t *testing.T) { + journal, plan := openTestCommitJournal(t) + original := journal.record.Plan.InnerOutputPaths[0] + plan.InnerOutputPaths[0] = filepath.Join(filepath.Dir(original), "mutated.json") + plan.PreviousRoot.Checkpoint.SHA256 = commitDigest("9") + plan.PreviousRootVersion.VersionID = "mutated-version" + if journal.record.Plan.InnerOutputPaths[0] != original { + t.Fatal("caller mutation changed the durable journal plan") + } + if journal.record.Plan.PreviousRoot.Checkpoint.SHA256 != commitDigest("2") || journal.record.Plan.PreviousRootVersion.VersionID != "parent-version" { + t.Fatal("caller mutation changed the durable prior root plan") + } + inner := []coordinatorCommitOutput{commitOutput(journal.record.Plan.InnerOutputPaths[0], "3"), commitOutput(journal.record.Plan.InnerOutputPaths[1], "4")} + if err := journal.recordInnerSigned(inner); err != nil { + t.Fatal(err) + } + checkpointIntent := testCheckpointIntent(filepath.Dir(original)) + checkpointIntent.CheckpointOutputPath = original + if err := journal.checkpointSigningIntent(checkpointIntent); err == nil { + t.Fatal("checkpoint intent reused an inner output path") + } +} + +func TestCoordinatorCommitJournalAuthenticatedChildMustMatchExactOutputsAndEvidence(t *testing.T) { + journal, plan := openTestCommitJournal(t) + checkpointIntent := testCheckpointIntent(filepath.Dir(plan.InnerOutputPaths[0])) + inner := []coordinatorCommitOutput{commitOutput(plan.InnerOutputPaths[0], "3"), commitOutput(plan.InnerOutputPaths[1], "4")} + if err := journal.recordInnerSigned(inner); err != nil { + t.Fatal(err) + } + if err := journal.checkpointSigningIntent(checkpointIntent); err != nil { + t.Fatal(err) + } + if err := journal.recordCheckpointSigned(commitOutput(checkpointIntent.CheckpointOutputPath, "5"), commitOutput(checkpointIntent.CheckpointSignatureOutputPath, "6")); err != nil { + t.Fatal(err) + } + valid := testAuthenticatedChild(filepath.Dir(plan.InnerOutputPaths[0]), plan) + cases := map[string]func(*coordinatorAuthenticatedChild){ + "artifact root": func(c *coordinatorAuthenticatedChild) { c.ArtifactRoot = "relative" }, + "checkpoint digest": func(c *coordinatorAuthenticatedChild) { c.Checkpoint.SHA256 = commitDigest("9") }, + "signature digest": func(c *coordinatorAuthenticatedChild) { c.CheckpointSignature.SHA256 = commitDigest("9") }, + "evidence ceremony": func(c *coordinatorAuthenticatedChild) { c.Evidence.CeremonyID = commitDigest("9") }, + "evidence digest": func(c *coordinatorAuthenticatedChild) { c.Evidence.CheckpointDigest = commitDigest("9") }, + "not fully verified": func(c *coordinatorAuthenticatedChild) { c.Evidence.FullyVerified = false }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + candidate := valid + mutate(&candidate) + if err := journal.recordAuthenticatedChild(candidate); err == nil { + t.Fatal("mismatched authenticated child accepted") + } + }) + } + if journal.stage() != commitCheckpointSigned { + t.Fatalf("failed child validation advanced journal to %q", journal.stage()) + } +} + +func TestCoordinatorCommitJournalSupportsExactInitialRootPlan(t *testing.T) { + root := t.TempDir() + plan := testCoordinatorCommitPlan(root) + plan.PreviousRoot = nil + plan.PreviousRootVersion = nil + journal, err := openOrCreateCoordinatorCommitJournal(filepath.Join(root, "journal.json"), plan) + if err != nil { + t.Fatal(err) + } + checkpointIntent := testCheckpointIntent(root) + if err := journal.recordInnerSigned([]coordinatorCommitOutput{commitOutput(plan.InnerOutputPaths[0], "3"), commitOutput(plan.InnerOutputPaths[1], "4")}); err != nil { + t.Fatal(err) + } + if err := journal.checkpointSigningIntent(checkpointIntent); err != nil { + t.Fatal(err) + } + if err := journal.recordCheckpointSigned(commitOutput(checkpointIntent.CheckpointOutputPath, "5"), commitOutput(checkpointIntent.CheckpointSignatureOutputPath, "6")); err != nil { + t.Fatal(err) + } + child := testAuthenticatedChild(root, plan) + child.Evidence.Sequence = 0 + if err := journal.recordAuthenticatedChild(child); err != nil { + t.Fatal(err) + } +} + +func TestCoordinatorCommitJournalDoesNotAdvanceMemoryWhenDurableWriteFails(t *testing.T) { + journal, plan := openTestCommitJournal(t) + journalDir := filepath.Dir(journal.path) + movedDir := journalDir + "-moved" + if err := os.Rename(journalDir, movedDir); err != nil { + t.Fatal(err) + } + inner := []coordinatorCommitOutput{commitOutput(plan.InnerOutputPaths[0], "3"), commitOutput(plan.InnerOutputPaths[1], "4")} + if err := journal.recordInnerSigned(inner); err == nil { + t.Fatal("journal claimed success when its directory was unavailable") + } + if journal.stage() != commitInnerSigningIntent { + t.Fatalf("in-memory stage advanced after failed durable write: %s", journal.stage()) + } + record, exists, err := readCoordinatorCommitJournal(filepath.Join(movedDir, filepath.Base(journal.path))) + if err != nil || !exists || record.Stage != commitInnerSigningIntent { + t.Fatalf("durable stage changed after failed write: %+v exists=%v err=%v", record, exists, err) + } +} + +func TestCoordinatorCommitJournalRejectsConflictingCompletedResume(t *testing.T) { + journal, plan := openTestCommitJournal(t) + checkpointIntent := testCheckpointIntent(filepath.Dir(plan.InnerOutputPaths[0])) + rootIntent := testRootIntent(filepath.Dir(plan.InnerOutputPaths[0])) + inner := []coordinatorCommitOutput{commitOutput(plan.InnerOutputPaths[0], "3"), commitOutput(plan.InnerOutputPaths[1], "4")} + if err := journal.recordInnerSigned(inner); err != nil { + t.Fatal(err) + } + if err := journal.checkpointSigningIntent(checkpointIntent); err != nil { + t.Fatal(err) + } + checkpoint := commitOutput(checkpointIntent.CheckpointOutputPath, "5") + signature := commitOutput(checkpointIntent.CheckpointSignatureOutputPath, "6") + if err := journal.recordCheckpointSigned(checkpoint, signature); err != nil { + t.Fatal(err) + } + if err := journal.recordCheckpointSigned(commitOutput(checkpointIntent.CheckpointOutputPath, "9"), signature); err == nil { + t.Fatal("changed checkpoint digest was accepted on resume") + } + child := testAuthenticatedChild(filepath.Dir(plan.InnerOutputPaths[0]), plan) + if err := journal.recordAuthenticatedChild(child); err != nil { + t.Fatal(err) + } + changedChild := child + changedChild.Evidence.TransitionKind = "phase1-receipt-accepted" + if err := journal.recordAuthenticatedChild(changedChild); err == nil { + t.Fatal("changed authenticated child evidence was accepted on resume") + } + rootPayload := commitOutput(rootIntent.RootPayloadOutputPath, "7") + if err := journal.rootCASIntent(rootIntent, rootPayload); err != nil { + t.Fatal(err) + } + if err := journal.rootCASIntent(rootIntent, commitOutput(rootIntent.RootPayloadOutputPath, "8")); err == nil { + t.Fatal("changed root payload was accepted on resume") + } + committed := committedRootVersion() + if err := journal.recordRootCASCommitted(committed); err != nil { + t.Fatal(err) + } + changedCommitted := committed + changedCommitted.VersionID = "different-version" + if err := journal.recordRootCASCommitted(changedCommitted); err == nil { + t.Fatal("changed committed object version was accepted on resume") + } +} + +func TestCoordinatorCommitJournalRejectsMalformedPlanAndRecord(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "journal.json") + plan := testCoordinatorCommitPlan(root) + cases := map[string]func(*coordinatorCommitPlan){ + "operation": func(p *coordinatorCommitPlan) { p.OperationID = "short" }, + "ceremony": func(p *coordinatorCommitPlan) { p.CeremonyID = "sha256:BAD" }, + "parent ceremony": func(p *coordinatorCommitPlan) { p.PreviousRoot.CeremonyID = commitDigest("9") }, + "parent etag": func(p *coordinatorCommitPlan) { p.PreviousRootVersion.ETag = "bad\nvalue" }, + "local output": func(p *coordinatorCommitPlan) { p.InnerOutputPaths[0] = "relative.json" }, + "duplicate output": func(p *coordinatorCommitPlan) { p.InnerOutputPaths[1] = p.InnerOutputPaths[0] }, + "missing root": func(p *coordinatorCommitPlan) { p.PreviousRoot = nil }, + "missing version": func(p *coordinatorCommitPlan) { p.PreviousRootVersion = nil }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + candidate := cloneCoordinatorCommitPlan(plan) + mutate(&candidate) + if _, err := openOrCreateCoordinatorCommitJournal(path, candidate); err == nil { + t.Fatal("malformed commit plan accepted") + } + }) + } + + journal, err := openOrCreateCoordinatorCommitJournal(path, plan) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(journal.path) + if err != nil { + t.Fatal(err) + } + var generic map[string]any + if err := json.Unmarshal(raw, &generic); err != nil { + t.Fatal(err) + } + generic["unknown"] = true + raw, _ = json.Marshal(generic) + if err := os.WriteFile(journal.path, raw, 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := readCoordinatorCommitJournal(journal.path); err == nil { + t.Fatal("unknown journal field accepted") + } +} + +func assertCommitJournalStage(t *testing.T, path string, want coordinatorCommitStage) { + t.Helper() + record, exists, err := readCoordinatorCommitJournal(path) + if err != nil { + t.Fatal(err) + } + if !exists || record.Stage != want { + t.Fatalf("journal stage = %q, exists=%v, want %q", record.Stage, exists, want) + } +} diff --git a/cmd/relay/coordinator_commit_v4.go b/cmd/relay/coordinator_commit_v4.go new file mode 100644 index 0000000..0c68cca --- /dev/null +++ b/cmd/relay/coordinator_commit_v4.go @@ -0,0 +1,140 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +// runCoordinatorCommitV4 publishes an already signed and fully checked V4 +// checkpoint. It is intended to run in the coordinator's online role image: +// proof-tool is pinned there and the coordinator storage credential is mounted +// read-only. The two immutable files are uploaded before the mutable root. +func runCoordinatorCommitV4(args []string) error { + set := flag.NewFlagSet("coordinator commit-v4", flag.ContinueOnError) + var storagePath, root, checkpoint, signature, ceremony, ceremonySignature, coordinatorKey, ceremonyBinary string + set.StringVar(&storagePath, "storage", "", "verified public storage configuration") + set.StringVar(&root, "artifact-root", "", "complete retained public artifact root") + set.StringVar(&checkpoint, "checkpoint", "", "signed child checkpoint under artifact-root") + set.StringVar(&signature, "checkpoint-signature", "", "detached child signature under artifact-root") + set.StringVar(&ceremony, "ceremony", "", "signed ceremony definition") + set.StringVar(&ceremonySignature, "ceremony-signature", "", "definition signature") + set.StringVar(&coordinatorKey, "coordinator-key", "", "independently authenticated coordinator public key") + set.StringVar(&ceremonyBinary, "ceremony-binary", "mpc-ceremony", "approved proof-tool executable") + if err := set.Parse(args); err != nil { + return err + } + for name, value := range map[string]string{ + "--storage": storagePath, "--artifact-root": root, "--checkpoint": checkpoint, + "--checkpoint-signature": signature, "--ceremony": ceremony, + "--ceremony-signature": ceremonySignature, "--coordinator-key": coordinatorKey, + } { + if value == "" || !filepath.IsAbs(value) || filepath.Clean(value) != value { + return fmt.Errorf("%s requires an absolute clean path", name) + } + } + config, err := loadStorageConfig(storagePath) + if err != nil { + return err + } + if config.CeremonyPath != ceremony || config.CeremonySignature != ceremonySignature || config.CoordinatorPublicKey != coordinatorKey { + return errors.New("storage configuration and exact authenticated ceremony paths differ") + } + checkpointName, err := publicationNameV4(root, checkpoint) + if err != nil { + return fmt.Errorf("checkpoint path: %w", err) + } + signatureName, err := publicationNameV4(root, signature) + if err != nil { + return fmt.Errorf("checkpoint signature path: %w", err) + } + checkpointProjection, err := workflowV4LocalRef(checkpointName, checkpoint) + if err != nil { + return err + } + signatureProjection, err := workflowV4LocalRef(signatureName, signature) + if err != nil { + return err + } + checkpointRef := state.ContentRef{Name: checkpointName, SHA256: checkpointProjection.Digest.SHA256, Size: checkpointProjection.Digest.Size} + signatureRef := state.ContentRef{Name: signatureName, SHA256: signatureProjection.Digest.SHA256, Size: signatureProjection.Digest.Size} + inspector := transcript.Inspector{Executable: ceremonyBinary, CeremonyPath: ceremony, CeremonySignaturePath: ceremonySignature, CoordinatorPublicKeyPath: coordinatorKey, TranscriptRoot: root} + child, err := storagefirst.AuthenticateRootChildV4(inspector, checkpointRef, signatureRef, root, checkpoint, signature) + if err != nil { + return err + } + objects := coordinatorClient(config, config.PublishedBucket) + for _, ref := range child.PublicationArtifacts() { + path := filepath.Join(root, filepath.FromSlash(ref.Name)) + if err := storagefirst.PublishImmutable(objects, ref, path, filepath.Dir(root)); err != nil { + return fmt.Errorf("publish required public artifact %q: %w", ref.Name, err) + } + } + if err := storagefirst.PublishImmutable(objects, checkpointRef, checkpoint, filepath.Dir(root)); err != nil { + return fmt.Errorf("publish immutable checkpoint: %w", err) + } + if err := storagefirst.PublishImmutable(objects, signatureRef, signature, filepath.Dir(root)); err != nil { + return fmt.Errorf("publish immutable checkpoint signature: %w", err) + } + + // Reread through authenticated provider access. A repeated command after a + // lost success response is complete only when the exact child is current. + temp, err := os.MkdirTemp(filepath.Dir(root), "relay-v4-root-read-") + if err != nil { + return err + } + defer os.RemoveAll(temp) + rootPath := filepath.Join(temp, "root.json") + commit := storagefirst.RootCommit{CeremonyID: config.CeremonyID, Child: child} + present, err := objects.Head(state.RootKey(config.CeremonyID)) + if err != nil { + return err + } + if present { + version, err := objects.GetVersionedAtMost(state.RootKey(config.CeremonyID), rootPath, 1<<20) + if err != nil { + return err + } + raw, err := os.ReadFile(rootPath) + if err != nil { + return err + } + current, err := state.DecodeRoot(raw) + if err != nil { + return err + } + if current.CeremonyID != config.CeremonyID { + return errors.New("authenticated storage root belongs to another ceremony") + } + if current.Checkpoint == checkpointRef && current.CheckpointSignature == signatureRef { + fmt.Printf("V4 checkpoint was already the exact authenticated storage head: %s\n", checkpointRef.SHA256) + return nil + } + commit.Previous, commit.PreviousVersion = ¤t, &version + } + committed, err := storagefirst.CommitRoot(objects, commit, filepath.Dir(root)) + if err != nil { + return err + } + fmt.Printf("Published the exact signed V4 checkpoint as the storage head: %s (ETag %s)\n", checkpointRef.SHA256, committed.ETag) + return nil +} + +func publicationNameV4(root, file string) (string, error) { + relative, err := filepath.Rel(root, file) + if err != nil || relative == "." || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", errors.New("file must be contained by artifact-root") + } + name := filepath.ToSlash(relative) + if err := transcript.ValidateName(name); err != nil { + return "", err + } + return name, nil +} diff --git a/cmd/relay/coordinator_enrollment_v4.go b/cmd/relay/coordinator_enrollment_v4.go new file mode 100644 index 0000000..43fd575 --- /dev/null +++ b/cmd/relay/coordinator_enrollment_v4.go @@ -0,0 +1,61 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/storagefirst" +) + +// runCoordinatorFetchEnrollmentV4 downloads one exact immutable enrollment +// transport attempt. It does not authenticate or accept the enrollment; the +// network-disabled proof-tool record-v4 step does that before publication. +func runCoordinatorFetchEnrollmentV4(args []string) error { + set := flag.NewFlagSet("coordinator fetch-enrollment-v4", flag.ContinueOnError) + var storagePath, attempt, out string + set.StringVar(&storagePath, "storage", "", "verified storage configuration") + set.StringVar(&attempt, "attempt-id", "", "exact enrollment transport attempt") + set.StringVar(&out, "out-dir", "", "fresh private download directory") + if err := set.Parse(args); err != nil { + return err + } + for name, value := range map[string]string{"--storage": storagePath, "--out-dir": out} { + if value == "" || !filepath.IsAbs(value) || filepath.Clean(value) != value { + return fmt.Errorf("%s requires an absolute clean path", name) + } + } + if attempt == "" { + return errors.New("--attempt-id is required") + } + if _, err := os.Lstat(out); err == nil { + return errors.New("enrollment download directory already exists") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if err := ensurePrivateDirectory(filepath.Dir(out)); err != nil { + return err + } + config, err := loadStorageConfig(storagePath) + if err != nil { + return err + } + scope := storagefirst.DeliveryScope{CeremonyID: config.CeremonyID, AttemptID: attempt, Kind: access.SubmissionKindEnrollment} + inventory := storagefirst.DeliveryInventory{"enrollment.json": 16 << 20, "enrollment.sig": 4096, "disclosure.txt": 1 << 20} + temporary, err := storagefirst.FetchDelivery(coordinatorClient(config, config.InboxBucket), scope, inventory, filepath.Dir(out)) + if err != nil { + return err + } + if err := os.Rename(temporary, out); err != nil { + _ = os.RemoveAll(temporary) + return err + } + if err := syncDirectory(filepath.Dir(out)); err != nil { + return err + } + fmt.Printf("Downloaded the exact immutable enrollment attempt to %s. It is not accepted until proof-tool verification and a signed checkpoint succeed.\n", out) + return nil +} diff --git a/cmd/relay/coordinator_local.go b/cmd/relay/coordinator_local.go index a345dbf..75d6370 100644 --- a/cmd/relay/coordinator_local.go +++ b/cmd/relay/coordinator_local.go @@ -24,6 +24,9 @@ func localTestCommandAllowed(role string, command []string, credentials bool) bo if role == "keygen" { return command[1] == "identity" && command[2] == "generate" } + if role == "decision-signer" { + return command[1] == "checkpoint" && (command[2] == "initialize-v4" || command[2] == "verify-stored-v4") + } if role != "coordinator" { return false } @@ -163,7 +166,7 @@ func runCoordinatorLocal(args []string) error { return errors.New("operation disabled in local test harness") } image := images["online"] - if role == "keygen" { + if role == "keygen" || role == "decision-signer" { image = images["offline"] } encoded := strings.Join(command, "\x00") + image diff --git a/cmd/relay/coordinator_navigation.go b/cmd/relay/coordinator_navigation.go index c58fbf5..48c423f 100644 --- a/cmd/relay/coordinator_navigation.go +++ b/cmd/relay/coordinator_navigation.go @@ -13,7 +13,7 @@ type preparationNext struct{ choice, label, reason string } // An allowed alternative is not the recommended next action. These choices // follow a sufficient roster; imported website assignments remain website-owned. func (w *coordinatorWizard) showOptionalIdentityImport(next preparationNext) bool { - if w.d.Status != "draft" || w.d.Tessera != nil || w.d.TesseraSetup != nil { + if w.d.Status != "draft" || w.d.Tessera != nil || w.d.TesseraSetup != nil || w.d.TesseraSetupV3 != nil { return false } switch next.choice { @@ -41,7 +41,7 @@ func (w *coordinatorWizard) nextPreparationAction() preparationNext { return preparationNext{"13", "Prepare, review and sign MY coordinator enrollment", "Bind your key to the signed coordinator assignment. This proves key control, not independent people. Existing output is reviewed before reuse."} } } - if w.d.Tessera != nil || w.d.TesseraSetup != nil { + if w.d.Tessera != nil || w.d.TesseraSetup != nil || w.d.TesseraSetupV3 != nil { if w.tesseraExportPresent() { return preparationNext{"0", "Save and exit — return to Tessera", "Setup exported. Upload it to Tessera, review and lock the setup. Export is not proof of website acceptance. Export again or explicitly continue to operations below."} } @@ -69,7 +69,7 @@ func (w *coordinatorWizard) nextPreparationAction() preparationNext { ids = append(ids, p.Identity) } seenID, seenKey, seenPub := map[string]bool{}, map[string]bool{}, map[string]bool{} - rosterReady := len(d.Identities.Auditors) >= 1 && len(d.Identities.Roster) > 0 + rosterReady := len(d.Identities.Roster) > 0 for _, id := range ids { if id.check() != nil || seenID[id.ID] || seenKey[id.KeyID] || seenPub[id.Fingerprint] { rosterReady = false @@ -77,7 +77,7 @@ func (w *coordinatorWizard) nextPreparationAction() preparationNext { seenID[id.ID], seenKey[id.KeyID], seenPub[id.Fingerprint] = true, true, true } if !rosterReady { - return preparationNext{"3", "Import or correct the required public identities", "The definition needs a coordinator, final signer, at least one auditor, and at least one participant with distinct keys. Witness and mirror enrollments come after initialization."} + return preparationNext{"3", "Import or correct the required public identities", "The definition needs a coordinator, final signer and at least one participant with distinct keys. Add auditors only when the assurance policy will require ceremony audits; witnesses and mirrors enroll after initialization."} } if d.ArchitecturePolicy != "" && d.ArchitecturePolicy != "both" && d.ArchitecturePolicy != "single" && d.ArchitecturePolicy != "custom" { return preparationNext{"5", "Review supported computers", "Correct the saved architecture selection. Both supported Linux architectures are selected by default."} diff --git a/cmd/relay/coordinator_navigation_test.go b/cmd/relay/coordinator_navigation_test.go index ac577f8..9585ef3 100644 --- a/cmd/relay/coordinator_navigation_test.go +++ b/cmd/relay/coordinator_navigation_test.go @@ -24,7 +24,7 @@ func TestCoordinatorPreparationRecommendations(t *testing.T) { } }}, {"recover identity", "3", func(w *coordinatorWizard) { w.d.Identities.Coordinator = setupIdentity{} }}, - {"missing auditor", "3", func(w *coordinatorWizard) { w.d.Identities.Auditors = nil }}, + {"missing auditor policy", "4", func(w *coordinatorWizard) { w.d.Identities.Auditors = nil }}, {"duplicate key", "3", func(w *coordinatorWizard) { w.d.Identities.ReleaseSigner = w.d.Identities.Coordinator }}, {"invalid policy", "4", func(w *coordinatorWizard) { w.d.Policy.Phase1.Minimum = 0 }}, {"invalid architecture", "5", func(w *coordinatorWizard) { w.d.ArchitecturePolicy = "invalid" }}, @@ -137,7 +137,7 @@ func TestCoordinatorOptionalIdentityShortcut(t *testing.T) { {"approval", func(w *coordinatorWizard) { w.d.OfflinePreparation = true }, true}, {"basics", func(w *coordinatorWizard) { w.d.Mode = "" }, false}, {"missing coordinator", func(w *coordinatorWizard) { w.d.Identities.Coordinator = setupIdentity{} }, false}, - {"required roster", func(w *coordinatorWizard) { w.d.Identities.Auditors = nil }, false}, + {"optional auditor", func(w *coordinatorWizard) { w.d.Identities.Auditors = nil }, true}, {"frozen", func(w *coordinatorWizard) { w.d.Status = "initialization-attempted" }, false}, {"signed", func(w *coordinatorWizard) { w.d.Status = "definition-verified" }, false}, {"website", func(w *coordinatorWizard) { w.d.Tessera = &tesseraContext{} }, false}, diff --git a/cmd/relay/coordinator_prepare.go b/cmd/relay/coordinator_prepare.go index 2760a7e..bb8547a 100644 --- a/cmd/relay/coordinator_prepare.go +++ b/cmd/relay/coordinator_prepare.go @@ -22,6 +22,7 @@ import ( "syscall" "time" + setupv3 "github.com/zksecurity/relay/contracts/setupv3" releaseassets "github.com/zksecurity/relay/release" ) @@ -58,16 +59,24 @@ type setupBeacon struct { Lead uint32 `json:"minimum_witness_lead_seconds"` Future bool `json:"future_round_required"` } +type setupAssurance struct { + PublicWitnessesPerPhase uint8 `json:"public_witnesses_per_phase"` + MirrorsPerAcceptedHead uint8 `json:"mirrors_per_accepted_head"` + PassingCeremonyAudits uint8 `json:"passing_ceremony_audits"` + ExternalSecurityAuditSignoffs uint8 `json:"external_security_audit_signoffs"` +} type setupPolicy struct { - Phase1 setupPhase `json:"phase1_policy"` - Phase2 setupPhase `json:"phase2_policy"` - Beacon setupBeacon `json:"beacon_policy"` + Phase1 setupPhase `json:"phase1_policy"` + Phase2 setupPhase `json:"phase2_policy"` + Beacon setupBeacon `json:"beacon_policy"` + Assurance *setupAssurance `json:"assurance_policy,omitempty"` } type setupBinary struct{ Path, SHA256 string } type coordinatorDraft struct { TesseraExportPath string `json:"tessera_export_path,omitempty"` TesseraExportSHA256 string `json:"tessera_export_sha256,omitempty"` TesseraSetup *setupv2.Setup `json:"tessera_setup,omitempty"` + TesseraSetupV3 *setupv3.Setup `json:"tessera_setup_v3,omitempty"` Tessera *tesseraContext `json:"tessera_context,omitempty"` ArchitecturePolicy string `json:"architecture_policy,omitempty"` Schema, Name, Release, Work, Trust, Keys string @@ -163,8 +172,11 @@ func (d coordinatorDraft) validate() error { if d.ArchitecturePolicy != "" && d.ArchitecturePolicy != "both" && d.ArchitecturePolicy != "single" && d.ArchitecturePolicy != "custom" { return errors.New("invalid architecture policy") } - if len(d.Identities.Auditors) < 1 || len(d.Identities.Roster) == 0 { - return errors.New("assign at least one auditor, a final-parameter signer and one participant") + if len(d.Identities.Roster) == 0 { + return errors.New("assign a final-parameter signer and at least one participant") + } + if d.Policy.Assurance == nil && len(d.Identities.Auditors) < 1 { + return errors.New("legacy policy requires at least one auditor") } roster := map[string]bool{} for _, p := range d.Identities.Roster { @@ -185,6 +197,25 @@ func (d coordinatorDraft) validate() error { if d.Policy.Beacon.Provider == "" || !d.Policy.Beacon.Future || d.Policy.Beacon.Lead == 0 { return errors.New("import a reviewed beacon policy requiring a future round and witness lead time") } + if d.Policy.Assurance != nil { + if d.Policy.Assurance.PublicWitnessesPerPhase > 20 || d.Policy.Assurance.MirrorsPerAcceptedHead > 20 || + d.Policy.Assurance.PassingCeremonyAudits > 20 || d.Policy.Assurance.ExternalSecurityAuditSignoffs > 20 { + return errors.New("assurance requirements must be between 0 and 20") + } + if int(d.Policy.Assurance.PassingCeremonyAudits) > len(d.Identities.Auditors) { + return errors.New("required ceremony audits exceed the assigned auditor count") + } + if d.Policy.Assurance.PassingCeremonyAudits == 0 && len(d.Identities.Auditors) != 0 { + return errors.New("remove auditor assignments or require at least one ceremony audit") + } + if d.Mode == "rehearsal" && d.Policy.Assurance.ExternalSecurityAuditSignoffs != 0 { + return errors.New("rehearsals cannot require external security-audit signoffs") + } + if d.Policy.Assurance.PublicWitnessesPerPhase != 0 || d.Policy.Assurance.MirrorsPerAcceptedHead != 0 || + d.Policy.Assurance.PassingCeremonyAudits != 0 || d.Policy.Assurance.ExternalSecurityAuditSignoffs != 0 { + return errors.New("this Relay release does not yet guide enabled witness, mirror, or audit journeys; disable all optional assurance controls") + } + } return nil } @@ -453,8 +484,11 @@ func (w *coordinatorWizard) summary() { fmt.Fprintf(w.output, "Phase %d order: %s; at least %d contributions required.\n", n+1, strings.Join(p.Participants, " -> "), p.Minimum) } b := w.d.Policy.Beacon - fmt.Fprintf(w.output, "Beacon: %s / %s; witnesses get at least %d seconds of lead time; future round required: %t.\nBeacon chain hash: %s\n", b.Provider, b.Network, b.Lead, b.Future, b.ChainHash) + fmt.Fprintf(w.output, "Beacon: %s / %s; the selected future round is at least %d seconds after closure; future round required: %t.\nBeacon chain hash: %s\n", b.Provider, b.Network, b.Lead, b.Future, b.ChainHash) fmt.Fprintln(w.output, "The beacon supplies public randomness after contributions close. The full policy is saved in draft.json; proof-tool validates it before signing.") + if a := w.d.Policy.Assurance; a != nil { + fmt.Fprintf(w.output, "Optional assurance: %d witness(es) per phase; %d mirror confirmation(s) per accepted contribution; %d passing ceremony audit(s); %d external security-audit signoff(s).\n", a.PublicWitnessesPerPhase, a.MirrorsPerAcceptedHead, a.PassingCeremonyAudits, a.ExternalSecurityAuditSignoffs) + } for _, b := range w.d.Binaries { fmt.Fprintf(w.output, "Additional allowed proof-tool binary: %s SHA-256 %s\n", b.Path, b.SHA256) } @@ -539,7 +573,7 @@ func (w *coordinatorWizard) policy() error { return fmt.Errorf("invalid built-in policy: %w", err) } b := standard.Beacon - standardLabel := fmt.Sprintf("Use the standard beacon settings included with this Relay release\n Source: %s / %s; a round every %d seconds\n Future round required: %t; minimum challenge: %d bytes\n Template witness lead time: %d seconds (for rehearsal).\n This is the minimum time from a witness's observation of the closed phase to the beacon round.\n Production requires at least 24 hours of witness lead time, not this short rehearsal setting.\n Network identity and verification key are pinned in this release.", b.Provider, b.Network, b.Period, b.Future, b.Challenge, b.Lead) + standardLabel := fmt.Sprintf("Use the standard beacon source included with this Relay release\n Source: %s / %s; a round every %d seconds\n Future round required: %t; minimum challenge: %d bytes\n Default closure-to-beacon wait: 180 seconds for rehearsal, 86400 seconds for production.\n You can review and change the wait before initialization.\n Network identity and verification key are pinned in this release.", b.Provider, b.Network, b.Period, b.Future, b.Challenge) choices := []setupChoice{{"standard", standardLabel}, {"custom", "Advanced: load a custom policy file"}} defaultChoice := "standard" if w.d.Policy.Beacon.Provider != "" { @@ -600,11 +634,104 @@ func (w *coordinatorWizard) policy() error { fmt.Fprintf(w.output, "Enter a number from 1 to %d.\n", len(order)) } } + defaultLead := uint32(180) + if w.d.Mode == "production" { + defaultLead = 24 * 60 * 60 + } + if selection == "current" || selection == "custom" { + defaultLead = policy.Beacon.Lead + } + for { + answer, askErr := w.required("Seconds from phase closure to the chosen future beacon round", strconv.FormatUint(uint64(defaultLead), 10)) + if askErr != nil { + return askErr + } + value, parseErr := strconv.ParseUint(answer, 10, 32) + if parseErr == nil && value > 0 { + policy.Beacon.Lead = uint32(value) + break + } + fmt.Fprintln(w.output, "Enter a positive whole number of seconds.") + } + if w.d.Mode == "production" && policy.Beacon.Lead < 24*60*60 { + fmt.Fprintf(w.output, "WARNING: production defaults to 86400 seconds (24 hours). You selected %d seconds. This leaves less time to detect a premature or incorrect closure before the beacon becomes known.\n", policy.Beacon.Lead) + if err := w.confirm("Sign this shorter production beacon wait into the immutable ceremony definition", "USE SHORTER PRODUCTION WAIT"); err != nil { + return err + } + } + currentAssurance := setupAssurance{} + if policy.Assurance != nil { + currentAssurance = *policy.Assurance + } + assuranceChoices := []setupChoice{ + {"none", "Recommended for this release — disable optional witnesses, mirrors and audits"}, + {"enabled", "Future workflow — enable witnesses, mirrors or audits (not available in this release)"}, + {"custom", "Advanced — choose each requirement (nonzero values are not available in this release)"}, + } + assuranceDefault := "none" + if policy.Assurance != nil { + assuranceChoices = append([]setupChoice{{"current", "Keep the saved assurance requirements"}}, assuranceChoices...) + assuranceDefault = "current" + } + assuranceChoice, err := w.choose("Optional assurance controls", assuranceDefault, assuranceChoices) + if err != nil { + return err + } + switch assuranceChoice { + case "current": + case "enabled": + currentAssurance = setupAssurance{PublicWitnessesPerPhase: 1, MirrorsPerAcceptedHead: 1, PassingCeremonyAudits: 1} + case "none": + currentAssurance = setupAssurance{} + case "custom": + fields := []struct { + label string + value *uint8 + }{ + {"Public witnesses required per phase", ¤tAssurance.PublicWitnessesPerPhase}, + {"Mirror confirmations required per accepted contribution", ¤tAssurance.MirrorsPerAcceptedHead}, + {"Passing ceremony audits required", ¤tAssurance.PassingCeremonyAudits}, + } + for _, field := range fields { + for { + answer, askErr := w.required(field.label, strconv.Itoa(int(*field.value))) + if askErr != nil { + return askErr + } + value, parseErr := strconv.ParseUint(answer, 10, 8) + if parseErr == nil && value <= 20 { + *field.value = uint8(value) + break + } + fmt.Fprintln(w.output, "Enter a whole number from 0 to 20.") + } + } + fmt.Fprintln(w.output, "External security-audit signoffs are not available in this release; keep that requirement at 0.") + } + if currentAssurance.PassingCeremonyAudits > uint8(len(w.d.Identities.Auditors)) { + return fmt.Errorf("%d ceremony audits require at least that many assigned auditors", currentAssurance.PassingCeremonyAudits) + } + removeAuditors := currentAssurance.PassingCeremonyAudits == 0 && len(w.d.Identities.Auditors) != 0 + if w.d.Mode == "rehearsal" && currentAssurance.ExternalSecurityAuditSignoffs != 0 { + return errors.New("rehearsals must set external security-audit signoffs to zero") + } + if currentAssurance.PublicWitnessesPerPhase != 0 || currentAssurance.MirrorsPerAcceptedHead != 0 || + currentAssurance.PassingCeremonyAudits != 0 || currentAssurance.ExternalSecurityAuditSignoffs != 0 { + return errors.New("enabled witness, mirror, and audit journeys are not available in this Relay release; choose the recommended disabled option") + } + policy.Assurance = ¤tAssurance b = policy.Beacon - fmt.Fprintf(w.output, "Beacon: %s / %s. Witnesses must observe at least %d seconds before the beacon round. Future round required: %t.\nThe beacon provides public randomness after contributions close; Relay does not substitute another round.\nChain hash: %s\nPublic key: %s\n", b.Provider, b.Network, b.Lead, b.Future, b.ChainHash, b.PublicKey) + fmt.Fprintf(w.output, "Beacon: %s / %s. The selected future beacon round is at least %d seconds after closure. Future round required: %t.\nThe beacon provides public randomness after contributions close; Relay does not substitute another round.\nChain hash: %s\nPublic key: %s\n", b.Provider, b.Network, b.Lead, b.Future, b.ChainHash, b.PublicKey) + fmt.Fprintf(w.output, "Optional assurance: %d witness(es) per phase; %d mirror confirmation(s) per accepted contribution; %d passing ceremony audit(s); %d external security-audit signoff(s).\n", currentAssurance.PublicWitnessesPerPhase, currentAssurance.MirrorsPerAcceptedHead, currentAssurance.PassingCeremonyAudits, currentAssurance.ExternalSecurityAuditSignoffs) + if removeAuditors { + fmt.Fprintf(w.output, "Disabling ceremony audits will remove %d auditor assignment(s) from this unsigned draft. Their local keys and identity files are not deleted.\n", len(w.d.Identities.Auditors)) + } if err := w.confirm("Review these beacon settings and the participant orders/minimums you selected; proof-tool still validates the complete policy before signing", "REVIEWED"); err != nil { return err } + if removeAuditors { + w.d.Identities.Auditors = nil + } w.d.Policy = policy w.d.PolicyTemplate = path return w.save() @@ -833,11 +960,17 @@ func (w *coordinatorWizard) generateIdentity() error { func (w *coordinatorWizard) initialize() error { resume := w.d.Status == "initialization-attempted" - if w.d.Tessera != nil || w.d.TesseraSetup != nil { + if w.d.Tessera != nil || w.d.TesseraSetup != nil || w.d.TesseraSetupV3 != nil { if err := checkTesseraDraft(w.d); err != nil { return err } } + if w.d.TesseraSetupV3 != nil && w.d.Mode == "production" && w.d.Policy.Beacon.Lead < 24*60*60 { + fmt.Fprintf(w.output, "WARNING: this imported production setup selects a %d-second closure-to-beacon wait; the recommended default is 86400 seconds (24 hours).\n", w.d.Policy.Beacon.Lead) + if err := w.confirm("Approve the exact shorter wait imported from Tessera before signing the definition", "USE SHORTER PRODUCTION WAIT"); err != nil { + return err + } + } if w.localAction != nil && (w.d.Mode != "rehearsal" || w.d.Circuit != "rehearsal-tiny-v1" || len(w.d.Binaries) != 0) { return errors.New("local tests require the tiny rehearsal circuit and the supplied local images only") } @@ -914,7 +1047,7 @@ func (w *coordinatorWizard) initialize() error { if err := ensurePrivateDirectory(root); err != nil { return err } - command := []string{"mpc-ceremony", "init", "--mode", w.d.Mode, "--key-version", w.d.Circuit, "--created-at", w.d.CreatedAt, "--session-nonce-hex", w.d.SessionNonceHex, "--participants", "/work/coordinator-setup/frozen/participants.json", "--policy", "/work/coordinator-setup/frozen/policy.json", "--coordinator-key-id", w.d.Identities.Coordinator.KeyID, "--coordinator-signing-key", "/keys/signing.hex", "--out-dir", "/work/ceremony/public"} + command := []string{"mpc-ceremony", "init", "--mode", w.d.Mode, "--key-version", w.d.Circuit, "--created-at", w.d.CreatedAt, "--session-nonce-hex", w.d.SessionNonceHex, "--participants", "/work/coordinator-setup/frozen/participants.json", "--policy", "/work/coordinator-setup/frozen/policy.json", "--coordinator-key-id", w.d.Identities.Coordinator.KeyID, "--coordinator-signing-key", "/keys/signing.hex", "--release-verification", "coordinator-full-replay-v1", "--out-dir", "/work/ceremony/public"} for n, b := range w.d.Binaries { // Copy first, then hash the copy. Never execute a host-provided binary. target := filepath.Join(snapshot, fmt.Sprintf("allowed-%d", n)) @@ -955,8 +1088,50 @@ func (w *coordinatorWizard) initialize() error { if err := w.action("initialize", "coordinator", command, false, resume); err != nil { return err } + if err := w.initializeCheckpointV4(filepath.Join(root, "public")); err != nil { + return err + } return w.verify() } + +func (w *coordinatorWizard) initializeCheckpointV4(root string) error { + definition := filepath.Join(root, "ceremony.json") + raw, err := readTesseraRegularFile(definition, 16<<20, false) + if err != nil { + return err + } + if err := rejectCommitJournalDuplicateFields(raw); err != nil { + return fmt.Errorf("initialized definition: %w", err) + } + var hint struct { + Schema string `json:"schema"` + } + if err := json.Unmarshal(raw, &hint); err != nil { + return err + } + if hint.Schema != "proof-tool-mpc-ceremony-definition-v4" { + // Existing V1-V3 ceremonies retain their released workflow and never gain + // V4 state merely because Relay was upgraded. + return nil + } + parent := filepath.Join(root, "checkpoints") + if err := ensurePrivateDirectory(parent); err != nil { + return err + } + out := filepath.Join(parent, "initial") + record, signature := filepath.Join(out, "checkpoint.json"), filepath.Join(out, "checkpoint.sig") + recordOK, signatureOK := regularPreparationFile(record), regularPreparationFile(signature) + if recordOK != signatureOK { + return errors.New("initial checkpoint output is incomplete; preserve it and inspect before retrying") + } + base := []string{"mpc-ceremony", "checkpoint"} + if recordOK { + command := append(base, "verify-stored-v4", "--ceremony", "/work/ceremony/public/ceremony.json", "--ceremony-signature", "/work/ceremony/public/ceremony.sig", "--coordinator-public-key-file", "/trust/setup-coordinator.hex", "--artifact-root", "/work/ceremony/public", "--checkpoint", "/work/ceremony/public/checkpoints/initial/checkpoint.json", "--checkpoint-signature", "/work/ceremony/public/checkpoints/initial/checkpoint.sig") + return w.action("verify-initial-checkpoint", "decision-signer", command, false) + } + command := append(base, "initialize-v4", "--ceremony", "/work/ceremony/public/ceremony.json", "--ceremony-signature", "/work/ceremony/public/ceremony.sig", "--coordinator-public-key-file", "/trust/setup-coordinator.hex", "--artifact-root", "/work/ceremony/public", "--coordinator-signing-key", "/keys/signing.hex", "--out-dir", "/work/ceremony/public/checkpoints/initial") + return w.action("initialize-checkpoint", "decision-signer", command, false) +} func (w *coordinatorWizard) verify() error { if w.d.Status == "draft" { return errors.New("nothing initialized yet") @@ -1049,7 +1224,17 @@ func (w *coordinatorWizard) configureStorage() error { args = append(args, "--"+field, value) } } - return w.action("storage", "coordinator", args, true) + if err := w.action("storage", "coordinator", args, true); err != nil { + return err + } + initial := filepath.Join(w.d.Work, "ceremony", "public", "checkpoints", "initial") + if !regularPreparationFile(filepath.Join(initial, "checkpoint.json")) || !regularPreparationFile(filepath.Join(initial, "checkpoint.sig")) { + // Existing V1-V3 ceremonies have no V4 root and retain the released + // publication path. New V4 initialization always creates this pair. + return nil + } + commit := []string{"relay", "coordinator", "commit-v4", "--storage", "/work/ceremony/config/relay-storage.json", "--artifact-root", "/work/ceremony/public", "--checkpoint", "/work/ceremony/public/checkpoints/initial/checkpoint.json", "--checkpoint-signature", "/work/ceremony/public/checkpoints/initial/checkpoint.sig", "--ceremony", "/work/ceremony/public/ceremony.json", "--ceremony-signature", "/work/ceremony/public/ceremony.sig", "--coordinator-key", "/trust/setup-coordinator.hex"} + return w.action("publish-initial-checkpoint", "coordinator", commit, true) } func (w *coordinatorWizard) menu() (result error) { @@ -1120,7 +1305,7 @@ func (w *coordinatorWizard) menu() (result error) { if w.d.Status == "draft" { fmt.Fprintln(w.output, "15) Open setup downloaded from Tessera [Optional]") } - if w.d.Status == "definition-verified" && (w.d.Tessera != nil || w.d.TesseraSetup != nil) && w.localAction == nil { + if w.d.Status == "definition-verified" && (w.d.Tessera != nil || w.d.TesseraSetup != nil || w.d.TesseraSetupV3 != nil) && w.localAction == nil { if next.choice != "16" || showOther { if w.tesseraExportPresent() { fmt.Fprintln(w.output, "16) Export again for Tessera") @@ -1157,7 +1342,7 @@ func (w *coordinatorWizard) menu() (result error) { showOther = true continue } - if !showOther && choice != next.choice && !(choice == "3" && optionalIdentity) && !(choice == "12" && w.d.Status == "definition-verified" && w.tesseraExportPresent()) && !(choice == "15" && w.d.Status == "draft") && !(choice == "16" && w.d.Status == "definition-verified" && (w.d.Tessera != nil || w.d.TesseraSetup != nil) && w.localAction == nil) { + if !showOther && choice != next.choice && !(choice == "3" && optionalIdentity) && !(choice == "12" && w.d.Status == "definition-verified" && w.tesseraExportPresent()) && !(choice == "15" && w.d.Status == "draft") && !(choice == "16" && w.d.Status == "definition-verified" && (w.d.Tessera != nil || w.d.TesseraSetup != nil || w.d.TesseraSetupV3 != nil) && w.localAction == nil) { fmt.Fprintln(w.output, "Choose a displayed action, or 17 to review other actions and requirements.") continue } diff --git a/cmd/relay/coordinator_prepare_test.go b/cmd/relay/coordinator_prepare_test.go index dba5229..03cba29 100644 --- a/cmd/relay/coordinator_prepare_test.go +++ b/cmd/relay/coordinator_prepare_test.go @@ -195,7 +195,7 @@ func setupFixture(t *testing.T) coordinatorWizard { } d.Identities = setupRoster{identity("coordinator"), identity("signer"), []setupIdentity{identity("auditor1"), identity("auditor2")}, []setupParticipant{{identity("participant1")}}} beacon := setupBeacon{Provider: "drand", Network: "quicknet-mainnet", ChainHash: "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", PublicKey: "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a", Scheme: "bls-unchained-g1-rfc9380", Genesis: 1692803367, Period: 3, Extraction: "sha256-domain-separated-length-prefixed-v1", Challenge: 32, Lead: 180, Future: true} - d.Policy = setupPolicy{setupPhase{[]string{"participant1"}, 1}, setupPhase{[]string{"participant1"}, 1}, beacon} + d.Policy = setupPolicy{Phase1: setupPhase{[]string{"participant1"}, 1}, Phase2: setupPhase{[]string{"participant1"}, 1}, Beacon: beacon} return coordinatorWizard{d: d, draftPath: filepath.Join(d.Work, "coordinator-setup", "draft.json"), input: bufio.NewReader(strings.NewReader("")), output: new(bytes.Buffer), run: func([]string) error { return nil }} } @@ -243,7 +243,7 @@ func TestCoordinatorPolicyBuiltInNeedsNoFile(t *testing.T) { w := setupFixture(t) w.d.Policy = setupPolicy{} w.d.PolicyTemplate = "/missing/source-checkout/policy.json" - w.input = bufio.NewReader(strings.NewReader("\n\n0\nno\n999\n1\n\n1\nREVIEWED\n")) + w.input = bufio.NewReader(strings.NewReader("\n\n0\nno\n999\n1\n\n1\n\n\nREVIEWED\n")) if err := w.policy(); err != nil { t.Fatal(err) } @@ -255,7 +255,7 @@ func TestCoordinatorPolicyBuiltInNeedsNoFile(t *testing.T) { if menuEnd < 0 { t.Fatal("missing beacon choice prompt") } - for _, want := range []string{"Source: drand / quicknet-mainnet; a round every 3 seconds", "Future round required: true; minimum challenge: 32 bytes", "Template witness lead time: 180 seconds", "Production requires at least 24 hours", "Network identity and verification key are pinned"} { + for _, want := range []string{"Source: drand / quicknet-mainnet; a round every 3 seconds", "Future round required: true; minimum challenge: 32 bytes", "Default closure-to-beacon wait: 180 seconds for rehearsal, 86400 seconds for production", "You can review and change the wait before initialization", "Network identity and verification key are pinned"} { if !strings.Contains(output[:menuEnd], want) { t.Fatalf("beacon menu missing %q before selection", want) } @@ -276,7 +276,7 @@ func TestCoordinatorPolicyRetainsSavedBeaconWithoutSourceFile(t *testing.T) { w := setupFixture(t) w.d.Policy.Beacon.Lead = 321 w.d.PolicyTemplate = "/no-longer-available/custom.json" - w.input = bufio.NewReader(strings.NewReader("\n\n\n\n\nREVIEWED\n")) + w.input = bufio.NewReader(strings.NewReader("\n\n\n\n\n\n\nREVIEWED\n")) if err := w.policy(); err != nil { t.Fatal(err) } @@ -285,6 +285,46 @@ func TestCoordinatorPolicyRetainsSavedBeaconWithoutSourceFile(t *testing.T) { } } +func TestCoordinatorPolicyAllowsReviewedShortProductionBeaconWait(t *testing.T) { + w := setupFixture(t) + w.d.Mode = "production" + w.d.Circuit = "ownership-destination-v2" + w.d.Identities.Auditors = nil + w.d.Policy.Assurance = &setupAssurance{} + w.input = bufio.NewReader(strings.NewReader("\n\n\n\n\n12\nUSE SHORTER PRODUCTION WAIT\n\nREVIEWED\n")) + if err := w.policy(); err != nil { + t.Fatal(err) + } + if w.d.Policy.Beacon.Lead != 12 { + t.Fatalf("beacon lead = %d, want 12", w.d.Policy.Beacon.Lead) + } + if output := w.output.(*bytes.Buffer).String(); !strings.Contains(output, "WARNING: production defaults to 86400 seconds") || !strings.Contains(output, "USE SHORTER PRODUCTION WAIT") { + t.Fatalf("missing short-production warning: %s", output) + } +} + +func TestCoordinatorDraftAllowsExplicitlyDisabledOptionalAssurance(t *testing.T) { + w := setupFixture(t) + w.d.Identities.Auditors = nil + w.d.Policy.Assurance = &setupAssurance{} + if err := w.d.validate(); err != nil { + t.Fatalf("explicitly disabled optional assurance rejected: %v", err) + } + w.d.Policy.Assurance = nil + if err := w.d.validate(); err == nil { + t.Fatal("legacy policy without its required auditor accepted") + } +} + +func TestCoordinatorDraftRejectsOptionalAssuranceWithoutGuidedJourneys(t *testing.T) { + w := setupFixture(t) + w.d.Identities.Auditors = nil + w.d.Policy.Assurance = &setupAssurance{PublicWitnessesPerPhase: 1} + if err := w.d.validate(); err == nil || !strings.Contains(err.Error(), "does not yet guide enabled witness") { + t.Fatalf("unsupported enabled assurance validation = %v", err) + } +} + func TestCoordinatorPolicyCustomAndCancelledReview(t *testing.T) { w := setupFixture(t) before, _ := json.Marshal(w.d) @@ -302,7 +342,7 @@ func TestCoordinatorPolicyCustomAndCancelledReview(t *testing.T) { if err := writeJSONNoReplace(path, custom, 0600); err != nil { t.Fatal(err) } - w.input = bufio.NewReader(strings.NewReader("3\n" + path + "\n\n\n\n\nREVIEWED\n")) + w.input = bufio.NewReader(strings.NewReader("3\n" + path + "\n\n\n\n\n\n\nREVIEWED\n")) if err := w.policy(); err != nil { t.Fatal(err) } @@ -422,9 +462,19 @@ func TestCoordinatorInitializationRequiresConsentAndFreezesOnFailure(t *testing. if err := w.initialize(); err == nil || calls != 1 || w.d.Status != "initialization-attempted" { t.Fatal("uncertain attempt not frozen") } - w.run = func([]string) error { calls++; return nil } + w.run = func(args []string) error { + calls++ + if strings.Contains(strings.Join(args, " "), "mpc-ceremony init") { + root := filepath.Join(w.d.Work, "ceremony", "public") + if err := os.MkdirAll(root, 0700); err != nil { + return err + } + return os.WriteFile(filepath.Join(root, "ceremony.json"), []byte(`{"schema":"proof-tool-mpc-ceremony-definition-v4"}`), 0600) + } + return nil + } w.input = bufio.NewReader(strings.NewReader("RESUME INITIALIZATION\n")) - if err := w.initialize(); err != nil || calls != 5 || w.d.Status != "definition-verified" { + if err := w.initialize(); err != nil || calls != 7 || w.d.Status != "definition-verified" { t.Fatal("exact interrupted initialization did not resume", err, calls, w.d.Status) } var frozen coordinatorDraft @@ -446,20 +496,30 @@ func TestCoordinatorInitializationUsesProofToolAndExternalTrust(t *testing.T) { w := setupFixture(t) w.input = bufio.NewReader(strings.NewReader("INITIALIZE REHEARSAL\n")) var calls [][]string - w.run = func(args []string) error { calls = append(calls, args); return nil } + w.run = func(args []string) error { + calls = append(calls, args) + if strings.Contains(strings.Join(args, " "), "mpc-ceremony init") { + root := filepath.Join(w.d.Work, "ceremony", "public") + if err := os.MkdirAll(root, 0700); err != nil { + return err + } + return os.WriteFile(filepath.Join(root, "ceremony.json"), []byte(`{"schema":"proof-tool-mpc-ceremony-definition-v4"}`), 0600) + } + return nil + } if err := w.initialize(); err != nil { t.Fatal(err) } - if len(calls) != 4 || w.d.Status != "definition-verified" { + if len(calls) != 6 || w.d.Status != "definition-verified" { t.Fatal(calls, w.d.Status) } joined := strings.Join(calls[0], " ") - for _, s := range []string{"mpc-ceremony init --mode rehearsal", "--key-version rehearsal-tiny-v1", "--session-nonce-hex", "--coordinator-signing-key /keys/signing.hex", "--out-dir /work/ceremony/public"} { + for _, s := range []string{"mpc-ceremony init --mode rehearsal", "--key-version rehearsal-tiny-v1", "--session-nonce-hex", "--coordinator-signing-key /keys/signing.hex", "--release-verification coordinator-full-replay-v1", "--out-dir /work/ceremony/public"} { if !strings.Contains(joined, s) { t.Fatal(joined) } } - if !strings.Contains(strings.Join(calls[2], " "), "--coordinator-public-key-file /trust/setup-coordinator.hex") { + if !strings.Contains(strings.Join(calls[2], " "), "checkpoint initialize-v4") || !strings.Contains(strings.Join(calls[4], " "), "--coordinator-public-key-file /trust/setup-coordinator.hex") { t.Fatal("missing independent trust anchor") } } @@ -515,7 +575,7 @@ func TestCoordinatorPrepareDocker(t *testing.T) { if w.d.Status != "definition-verified" { t.Fatal(w.d.Status) } - for _, name := range []string{"ceremony.json", "ceremony.sig", "phase1"} { + for _, name := range []string{"ceremony.json", "ceremony.sig", "phase1", "checkpoints/initial/checkpoint.json", "checkpoints/initial/checkpoint.sig"} { if _, err := os.Stat(filepath.Join(w.d.Work, "ceremony", "public", name)); err != nil { t.Fatal(err) } diff --git a/cmd/relay/coordinator_release_v4.go b/cmd/relay/coordinator_release_v4.go new file mode 100644 index 0000000..f820490 --- /dev/null +++ b/cmd/relay/coordinator_release_v4.go @@ -0,0 +1,60 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/storagefirst" +) + +// runCoordinatorFetchReleaseV4 downloads a dynamically sized signed release +// package through coordinator-authenticated inbox access. The trusted storage +// manifest frames transport only; proof-tool must authenticate the package. +func runCoordinatorFetchReleaseV4(args []string) error { + set := flag.NewFlagSet("coordinator fetch-release-v4", flag.ContinueOnError) + var storagePath, attempt, out string + set.StringVar(&storagePath, "storage", "", "verified storage configuration") + set.StringVar(&attempt, "attempt-id", "", "exact release transport attempt") + set.StringVar(&out, "out-dir", "", "fresh private release download directory") + if err := set.Parse(args); err != nil { + return err + } + for name, value := range map[string]string{"--storage": storagePath, "--out-dir": out} { + if value == "" || !filepath.IsAbs(value) || filepath.Clean(value) != value { + return fmt.Errorf("%s requires an absolute clean path", name) + } + } + if attempt == "" { + return errors.New("--attempt-id is required") + } + if _, err := os.Lstat(out); err == nil { + return errors.New("release download directory already exists") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if err := ensurePrivateDirectory(filepath.Dir(out)); err != nil { + return err + } + config, err := loadStorageConfig(storagePath) + if err != nil { + return err + } + scope := storagefirst.DeliveryScope{CeremonyID: config.CeremonyID, AttemptID: attempt, Kind: access.SubmissionKindRelease} + temporary, _, err := storagefirst.FetchReleaseDelivery(coordinatorClient(config, config.InboxBucket), scope, filepath.Dir(out)) + if err != nil { + return err + } + if err := os.Rename(temporary, out); err != nil { + _ = os.RemoveAll(temporary) + return err + } + if err := syncDirectory(filepath.Dir(out)); err != nil { + return err + } + fmt.Printf("Downloaded and transport-checked the signed release package to %s. It is not accepted; proof-tool must authenticate it.\n", out) + return nil +} diff --git a/cmd/relay/docker_driver.go b/cmd/relay/docker_driver.go index 5cbc6d3..6a6b93c 100644 --- a/cmd/relay/docker_driver.go +++ b/cmd/relay/docker_driver.go @@ -38,6 +38,8 @@ const ( dockerMacSwapUnassessed = "not-assessed-macos-host" ) +var errContributorNotCreated = errors.New("contributor container was not created") + type dockerCommandClient interface { Output(args ...string) ([]byte, []byte, error) Attached(stdout, stderr io.Writer, args ...string) error @@ -105,21 +107,25 @@ type dockerMount struct { } type dockerDriver struct { - image string - platform string - ceremonyBinary string - root string - definition string - definitionSig string - coordinatorKey string - signingKey string - environment string - candidateRoot string - client dockerCommandClient - now func() time.Time - hostSwapStatus func() (string, error) - interruptCtx func() (context.Context, context.CancelFunc) - daemon dockerDaemonFacts + image string + platform string + ceremonyBinary string + root string + inspectionRoot string // optional V4 work root; only exact requested paths are mounted + definition string + definitionSig string + coordinatorKey string + signingKey string + environment string + candidateRoot string + executionIntentPath string // optional private V4 retained invocation + beforeCreate func() error // V4 journal crosses running immediately before create + replaceUnstartedIntent bool + client dockerCommandClient + now func() time.Time + hostSwapStatus func() (string, error) + interruptCtx func() (context.Context, context.CancelFunc) + daemon dockerDaemonFacts } type dockerActiveState struct { @@ -136,6 +142,7 @@ type dockerActiveState struct { CreatedAt string `json:"created_at"` MountDigest string `json:"mount_digest,omitempty"` Mounts []dockerMount `json:"mounts,omitempty"` + CreateArgs []string `json:"create_args,omitempty"` } type dockerDaemonFacts struct { @@ -170,25 +177,29 @@ type dockerSecurityFacts struct { } type dockerLifecycleReceipt struct { - Schema string `json:"schema"` - ExecutionMode string `json:"execution_mode"` - Image string `json:"image"` - Platform string `json:"platform"` - CeremonyBinary string `json:"ceremony_binary"` - CeremonyBinarySHA256 string `json:"ceremony_binary_sha256"` - Daemon dockerDaemonFacts `json:"daemon"` - HostSwapStatus string `json:"host_swap_status"` - ContainerID string `json:"container_id"` - CreatedAt string `json:"created_at"` - StartedAt string `json:"started_at"` - ExitedAt string `json:"exited_at"` - ExitCode int `json:"exit_code"` - RemovedAt string `json:"removed_at"` - RemovalVerified bool `json:"removal_verified"` - Security dockerSecurityFacts `json:"security"` - ParticipantConfirmation string `json:"participant_confirmation,omitempty"` - ConfirmedAt string `json:"confirmed_at,omitempty"` - ErasureDestroyedAt string `json:"erasure_destroyed_at,omitempty"` + Schema string `json:"schema"` + OperationID string `json:"operation_id,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + CandidateDirectorySHA256 string `json:"candidate_directory_sha256,omitempty"` + CreateArgsSHA256 string `json:"create_args_sha256,omitempty"` + ExecutionMode string `json:"execution_mode"` + Image string `json:"image"` + Platform string `json:"platform"` + CeremonyBinary string `json:"ceremony_binary"` + CeremonyBinarySHA256 string `json:"ceremony_binary_sha256"` + Daemon dockerDaemonFacts `json:"daemon"` + HostSwapStatus string `json:"host_swap_status"` + ContainerID string `json:"container_id"` + CreatedAt string `json:"created_at"` + StartedAt string `json:"started_at"` + ExitedAt string `json:"exited_at"` + ExitCode int `json:"exit_code"` + RemovedAt string `json:"removed_at"` + RemovalVerified bool `json:"removal_verified"` + Security dockerSecurityFacts `json:"security"` + ParticipantConfirmation string `json:"participant_confirmation,omitempty"` + ConfirmedAt string `json:"confirmed_at,omitempty"` + ErasureDestroyedAt string `json:"erasure_destroyed_at,omitempty"` } func dockerDriverForParticipant(config access.ParticipantConfig) *dockerDriver { @@ -432,13 +443,6 @@ func (d *dockerDriver) contribution(o roleOpts, pos position, contributedAt time Image: d.image, Platform: d.platform, DaemonID: d.daemon.ID, DaemonEndpoint: d.daemon.Endpoint, HandoffDir: handoff, CreatedAt: receipt.CreatedAt, MountDigest: fmt.Sprintf("sha256:%x", mountDigest), Mounts: append([]dockerMount(nil), mounts...), } - // Save the intended unique name before Docker can create anything. If the - // create response is lost, cleanup resolves this exact labelled container - // by name instead of starting a second contributor. - if err := writeJSONNoReplace(d.activeStatePath(), state, 0o600); err != nil { - _ = os.RemoveAll(handoff) - return nil, fmt.Errorf("persist contributor intent: %w", err) - } createArgs := d.baseCreateArgs(mounts) createArgs = append(createArgs, "--name", state.ContainerName, @@ -449,6 +453,30 @@ func (d *dockerDriver) contribution(o roleOpts, pos position, contributedAt time d.image, ) createArgs = append(createArgs, args...) + // Persist the actual rewritten Docker invocation, not only the outer /work + // command. This contains paths and public arguments, never signing-key bytes. + // Older lifecycle records without CreateArgs remain usable for cleanup. + state.CreateArgs = append([]string(nil), createArgs...) + // Save before Docker can create anything. Lost responses are reconciled by + // this exact unique name, never by starting a second contributor. + if err := writeJSONNoReplace(d.activeStatePath(), state, 0o600); err != nil { + _ = os.RemoveAll(handoff) + return nil, fmt.Errorf("persist contributor intent: %w", err) + } + if d.executionIntentPath != "" { + writeIntent := writeJSONNoReplace + if d.replaceUnstartedIntent { + writeIntent = writeJSONAtomic + } + if err := writeIntent(d.executionIntentPath, state, 0o600); err != nil { + return nil, fmt.Errorf("persist V4 contributor invocation: %w", err) + } + } + if d.beforeCreate != nil { + if err := d.beforeCreate(); err != nil { + return nil, err + } + } stdout, stderr, err := d.client.Output(createArgs...) if err != nil { containerID, found, reconcileErr := d.resolveTrackedContainer(state) @@ -456,10 +484,28 @@ func (d *dockerDriver) contribution(o roleOpts, pos position, contributedAt time return nil, fmt.Errorf("create contributor response was uncertain and reconciliation failed: %w", reconcileErr) } if !found { - _ = os.Remove(d.activeStatePath()) - _ = syncDirectory(filepath.Dir(d.activeStatePath())) - _ = os.RemoveAll(handoff) - return nil, fmt.Errorf("create contributor container: %s", dockerDiagnostic(stderr, err)) + if removeErr := os.Remove(d.activeStatePath()); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + return nil, fmt.Errorf("create contributor failed and its retained intent could not be removed: %w", removeErr) + } + if syncErr := syncDirectory(filepath.Dir(d.activeStatePath())); syncErr != nil { + return nil, fmt.Errorf("create contributor failed and intent removal could not be synced: %w", syncErr) + } + if d.executionIntentPath != "" { + var retained dockerActiveState + if readErr := setupReadJSON(d.executionIntentPath, &retained); readErr != nil || !reflect.DeepEqual(retained, state) { + return nil, errors.New("create contributor failed but its V4 invocation record changed; inspect before continuing") + } + if removeErr := os.Remove(d.executionIntentPath); removeErr != nil { + return nil, fmt.Errorf("create contributor failed and its V4 invocation could not be removed: %w", removeErr) + } + if syncErr := syncDirectory(filepath.Dir(d.executionIntentPath)); syncErr != nil { + return nil, fmt.Errorf("create contributor failed and V4 invocation removal could not be synced: %w", syncErr) + } + } + if removeErr := os.RemoveAll(handoff); removeErr != nil { + return nil, fmt.Errorf("create contributor failed and its empty handoff could not be removed: %w", removeErr) + } + return nil, fmt.Errorf("%w: %s", errContributorNotCreated, dockerDiagnostic(stderr, err)) } stdout = []byte(containerID) } @@ -475,6 +521,13 @@ func (d *dockerDriver) contribution(o roleOpts, pos position, contributedAt time return nil, errors.New("persist contributor cleanup state: lifecycle state changed while Docker created the container") } receipt.ContainerID = containerID + receipt.OperationID = state.OperationID + receipt.WorkspaceID = state.WorkspaceID + destinationDigest := sha256.Sum256([]byte(filepath.Clean(o.outDir))) + receipt.CandidateDirectorySHA256 = fmt.Sprintf("sha256:%x", destinationDigest) + invocationBytes, _ := json.Marshal(state.CreateArgs) + invocationDigest := sha256.Sum256(invocationBytes) + receipt.CreateArgsSHA256 = fmt.Sprintf("sha256:%x", invocationDigest) state.ContainerID = containerID if err := writeJSONAtomic(d.activeStatePath(), state, 0o600); err != nil { _ = d.removeAndVerify(containerID) @@ -547,6 +600,13 @@ func (d *dockerDriver) contribution(o roleOpts, pos position, contributedAt time _ = os.RemoveAll(handoff) return nil, err } + // Publish the public contribution and its lifecycle evidence as one + // directory rename. A crash must not expose a candidate that cannot later + // prove which contributor container was removed. + if err := writeJSONNoReplace(filepath.Join(publicCandidate, dockerLifecycleLogName), receipt, 0o600); err != nil { + _ = os.RemoveAll(handoff) + return nil, fmt.Errorf("stage contributor lifecycle evidence: %w", err) + } if _, err := os.Lstat(o.outDir); err == nil || !errors.Is(err, os.ErrNotExist) { _ = os.RemoveAll(handoff) if err == nil { @@ -573,6 +633,16 @@ func (d *dockerDriver) contributionInterruptContext() (context.Context, context. } func (d *dockerDriver) attestErasure(o roleOpts, destroyedAt time.Time) error { + command, err := d.erasureCommand(o, destroyedAt) + if err != nil { + return err + } + return d.client.Attached(os.Stdout, os.Stderr, command...) +} + +// erasureCommand is shared with saved-operation execution so it can persist +// the exact rewritten invocation before starting the signing child. +func (d *dockerDriver) erasureCommand(o roleOpts, destroyedAt time.Time) ([]string, error) { args := []string{o.phase, "attest-erasure", "--ceremony", d.definition, "--ceremony-signature", d.definitionSig, @@ -584,7 +654,7 @@ func (d *dockerDriver) attestErasure(o roleOpts, destroyedAt time.Time) error { } rewritten, mounts, err := d.rewriteArgs(args, map[string]string{o.outDir: "/relay/output/candidate"}) if err != nil { - return err + return nil, err } for i := range mounts { if mounts[i].Source == o.outDir { @@ -594,7 +664,7 @@ func (d *dockerDriver) attestErasure(o roleOpts, destroyedAt time.Time) error { command := d.baseRunArgs(true, mounts) command = append(command, d.image) command = append(command, rewritten...) - return d.client.Attached(os.Stdout, os.Stderr, command...) + return command, nil } func (d *dockerDriver) contributionArgs(o roleOpts, pos position, contributedAt time.Time, handoff, output string) ([]string, []dockerMount, error) { @@ -606,6 +676,22 @@ func (d *dockerDriver) contributionArgs(o roleOpts, pos position, contributedAt container.signingKey = "/relay/key/participant.key" container.envPath = "/relay/config/environment.json" container.outDir = output + if container.attemptID != "" { + container.artifactRoot = "/relay/input" + if o.checkpoint == "" || o.checkpointSig == "" || o.checkpoint == o.checkpointSig { + return nil, nil, errors.New("V4 contribution requires distinct allocation checkpoint and signature paths") + } + for _, item := range []struct { + source string + destination *string + }{{o.checkpoint, &container.checkpoint}, {o.checkpointSig, &container.checkpointSig}} { + mapped, err := pathWithin(d.root, item.source, "/relay/input") + if err != nil { + return nil, nil, fmt.Errorf("allocation checkpoint path: %w", err) + } + *item.destination = mapped + } + } // Explicit phase-1 seal paths use the same read-only transcript mount as // default paths. Never pass a host path into the isolated contributor. for _, sealPath := range []*string{&container.phase1Seal, &container.phase1SealSig} { @@ -671,7 +757,20 @@ func (d *dockerDriver) rewriteArgs(args []string, writable map[string]string) ([ if filepath.IsAbs(arg) { mapped, err := pathWithin(d.root, arg, "/relay/input") if err != nil { - return nil, nil, fmt.Errorf("refuse unrecognized host path %q in Docker ceremony command", arg) + if d.inspectionRoot == "" || arg == d.inspectionRoot { + return nil, nil, fmt.Errorf("refuse unrecognized host path %q in Docker ceremony command", arg) + } + if _, scopeErr := pathWithin(d.inspectionRoot, arg, "/"); scopeErr != nil { + return nil, nil, fmt.Errorf("refuse unrecognized host path %q in Docker ceremony command", arg) + } + if mount, ok := mountBySource[arg]; ok { + rewritten[i] = mount.Destination + continue + } + mapped = fmt.Sprintf("/relay/extra/%d", i) + mountBySource[arg] = dockerMount{Source: arg, Destination: mapped, ReadOnly: true} + rewritten[i] = mapped + continue } rewritten[i] = mapped mountBySource[d.root] = dockerMount{Source: d.root, Destination: "/relay/input", ReadOnly: true} @@ -1157,7 +1256,7 @@ func signedCeremonyBinarySHA256(path, platform string) (string, error) { ) } digest = definition.Software.ToolBinary.SHA256 - case "proof-tool-mpc-ceremony-definition-v2": + case "proof-tool-mpc-ceremony-definition-v2", "proof-tool-mpc-ceremony-definition-v3", "proof-tool-mpc-ceremony-definition-v4": for _, binary := range definition.Software.Binaries { if binary.GoOS+"/"+binary.GoArch != platform { continue diff --git a/cmd/relay/docker_driver_test.go b/cmd/relay/docker_driver_test.go index 30f20f5..e7b1fa9 100644 --- a/cmd/relay/docker_driver_test.go +++ b/cmd/relay/docker_driver_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "runtime" "strings" "syscall" @@ -323,6 +324,43 @@ func TestDockerContributionRemovesContainerBeforePromotingPublicOutput(t *testin } } +func TestDockerInspectionMountsOnlyExactRequestedWorkFile(t *testing.T) { + work := t.TempDir() + root := filepath.Join(work, "ceremony", "public") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + record := filepath.Join(work, "my-enrollment", "canonical.json") + if err := os.MkdirAll(filepath.Dir(record), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(record, []byte("fixture"), 0o600); err != nil { + t.Fatal(err) + } + driver := dockerDriver{root: root, inspectionRoot: work} + rewritten, mounts, err := driver.rewriteReadOnlyArgs([]string{"--enrollment", record, "--same", record}) + if err != nil { + t.Fatal(err) + } + resolvedRecord, err := filepath.EvalSymlinks(record) + if err != nil { + t.Fatal(err) + } + if rewritten[1] != rewritten[3] || len(mounts) != 1 || mounts[0].Source != resolvedRecord || !mounts[0].ReadOnly { + t.Fatalf("inspection exposed more than the exact requested file: args=%v mounts=%+v", rewritten, mounts) + } + if _, _, err := driver.rewriteReadOnlyArgs([]string{"--bad", work}); err == nil { + t.Fatal("mounted the entire V4 work directory") + } + outside := filepath.Join(t.TempDir(), "outside.json") + if err := os.WriteFile(outside, []byte("fixture"), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := driver.rewriteReadOnlyArgs([]string{"--bad", outside}); err == nil { + t.Fatal("mounted a path outside the V4 work directory") + } +} + func TestDockerContributionAdoptsContainerAfterLostCreateResponse(t *testing.T) { o, pos, driver, fake := dockerContributionFixture(t) fake.createErrAfter = true @@ -569,30 +607,118 @@ func TestDockerContributionDoesNotReplaceConcurrentLifecycleState(t *testing.T) } } -func TestSignedCeremonyBinarySHA256SelectsConfiguredPlatform(t *testing.T) { - amdDigest := "sha256:" + strings.Repeat("a", 64) - armDigest := "sha256:" + strings.Repeat("b", 64) - definition := `{"schema":"proof-tool-mpc-ceremony-definition-v2","software":{"binaries":[` + - `{"goos":"linux","goarch":"amd64","goamd64":"v1","tool_binary":{"sha256":"` + amdDigest + `"}},` + - `{"goos":"linux","goarch":"arm64","goarm64":"v8.0","tool_binary":{"sha256":"` + armDigest + `"}}]}}` - path := filepath.Join(t.TempDir(), "ceremony.json") - if err := os.WriteFile(path, []byte(definition), 0o600); err != nil { +func TestDockerContributionPersistsActualCommandBeforeCreate(t *testing.T) { + o, pos, driver, fake := dockerContributionFixture(t) + checked := false + fake.onCreate = func() { + var state dockerActiveState + if err := setupReadJSON(driver.activeStatePath(), &state); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(state.CreateArgs, fake.createArgs) { + t.Fatal("saved intent differs from actual Docker invocation") + } + if !strings.Contains(strings.Join(state.CreateArgs, " "), "/relay/output/candidate") { + t.Fatal("missing rewritten candidate destination") + } + checked = true + } + if err := runNextAt(o, pos, time.Now()); err != nil { t.Fatal(err) } - for platform, want := range map[string]string{ - "linux/amd64": amdDigest, - "linux/arm64": armDigest, - } { - got, err := signedCeremonyBinarySHA256(path, platform) - if err != nil { - t.Fatalf("select %s: %v", platform, err) - } - if got != want { - t.Fatalf("select %s = %q, want %q", platform, got, want) + if !checked { + t.Fatal("create was not observed") + } +} + +func TestDockerContributionLaunchBoundary(t *testing.T) { + for _, preflightFailure := range []bool{false, true} { + t.Run(map[bool]string{false: "intent-before-boundary", true: "preflight-before-boundary"}[preflightFailure], func(t *testing.T) { + o, pos, d, fake := dockerContributionFixture(t) + d.executionIntentPath = filepath.Join(t.TempDir(), "intent.json") + if preflightFailure { + d.hostSwapStatus = func() (string, error) { return "", errors.New("preflight failed") } + } + crossed := false + d.beforeCreate = func() error { + crossed = true + var intent dockerActiveState + if err := setupReadJSON(d.executionIntentPath, &intent); err != nil { + t.Fatal(err) + } + if len(intent.CreateArgs) == 0 { + t.Fatal("running boundary precedes durable invocation") + } + return errors.New("boundary persistence failed") + } + if err := runNextAt(o, pos, time.Now()); err == nil { + t.Fatal("ignored prelaunch failure") + } + if crossed == preflightFailure { + t.Fatal("incorrect launch boundary ordering") + } + if len(fake.createArgs) != 0 { + t.Fatal("created contributor after failed prelaunch boundary") + } + }) + } +} + +func TestDockerContributionReportsProvenCreateNoEffect(t *testing.T) { + o, pos, driver, fake := dockerContributionFixture(t) + driver.executionIntentPath = filepath.Join(t.TempDir(), "intent.json") + driver.replaceUnstartedIntent = true + fake.createErrAfter = true + fake.onCreate = func() { fake.removed = true } + err := runNextAt(o, pos, time.Now()) + if !errors.Is(err, errContributorNotCreated) { + t.Fatalf("create failure = %v", err) + } + for _, path := range []string{driver.activeStatePath(), driver.executionIntentPath} { + if _, statErr := os.Lstat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("retained uncertain intent %s: %v", path, statErr) } } - if _, err := signedCeremonyBinarySHA256(path, "linux/riscv64"); err == nil { - t.Fatal("unlisted platform was accepted") + if _, statErr := os.Lstat(o.outDir); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("candidate unexpectedly exists: %v", statErr) + } +} + +func TestSignedCeremonyBinarySHA256SelectsConfiguredPlatform(t *testing.T) { + amdDigest := "sha256:" + strings.Repeat("a", 64) + armDigest := "sha256:" + strings.Repeat("b", 64) + for _, schema := range []string{"proof-tool-mpc-ceremony-definition-v2", "proof-tool-mpc-ceremony-definition-v3", "proof-tool-mpc-ceremony-definition-v4"} { + t.Run(schema, func(t *testing.T) { + definition := `{"schema":"` + schema + `","software":{"binaries":[` + + `{"goos":"linux","goarch":"amd64","goamd64":"v1","tool_binary":{"sha256":"` + amdDigest + `"}},` + + `{"goos":"linux","goarch":"arm64","goarm64":"v8.0","tool_binary":{"sha256":"` + armDigest + `"}}]}}` + path := filepath.Join(t.TempDir(), "ceremony.json") + if err := os.WriteFile(path, []byte(definition), 0o600); err != nil { + t.Fatal(err) + } + for platform, want := range map[string]string{ + "linux/amd64": amdDigest, + "linux/arm64": armDigest, + } { + got, err := signedCeremonyBinarySHA256(path, platform) + if err != nil { + t.Fatalf("select %s: %v", platform, err) + } + if got != want { + t.Fatalf("select %s = %q, want %q", platform, got, want) + } + } + if _, err := signedCeremonyBinarySHA256(path, "linux/riscv64"); err == nil { + t.Fatal("unlisted platform was accepted") + } + duplicate := strings.Replace(definition, `]}}`, `,{"goos":"linux","goarch":"arm64","tool_binary":{"sha256":"`+armDigest+`"}}]}}`, 1) + if err := os.WriteFile(path, []byte(duplicate), 0o600); err != nil { + t.Fatal(err) + } + if _, err := signedCeremonyBinarySHA256(path, "linux/arm64"); err == nil { + t.Fatal("duplicate platform was accepted") + } + }) } } diff --git a/cmd/relay/flow_directory_evidence.go b/cmd/relay/flow_directory_evidence.go index 84247a9..71c08b4 100644 --- a/cmd/relay/flow_directory_evidence.go +++ b/cmd/relay/flow_directory_evidence.go @@ -90,13 +90,13 @@ func (f *roleFlow) captureDirectories(task flowTask, command []string) (map[stri return nil, err } if !st.IsDir() || st.Mode()&os.ModeSymlink != 0 { - return nil, errors.New("public evidence input must be a real directory") + return nil, fmt.Errorf("public evidence input %s (%s) must be a real directory", field.Flag, value) } digest, err := flowTreeHash(local) if err != nil { return nil, err } - if strings.Contains(field.Flag, "transcript") { + if strings.Contains(field.Flag, "transcript") || commandWritesInside(task, command, value) { // Transcript growth is normal. Bind every retained file, allowing // new heads/beacons but never replacement or removal of old bytes. err = filepath.WalkDir(local, func(path string, e fs.DirEntry, err error) error { @@ -125,6 +125,29 @@ func (f *roleFlow) captureDirectories(task flowTask, command []string) (map[stri return result, nil } +// If a command writes an output below one of its input directories, bind all +// files that existed before the command instead of binding the directory as a +// closed tree. Existing bytes may not change or disappear, while the command +// may add its fresh output. The proof tool still authenticates the resulting +// output; this is only Relay's concurrent-change guard. +func commandWritesInside(task flowTask, command []string, input string) bool { + input = strings.TrimSuffix(filepath.Clean(input), "/") + "/" + for _, field := range append(append([]flowField{}, task.Fields...), task.ExtraFields...) { + if !flowOutputField(task, field) { + continue + } + for n, arg := range command { + if arg == "--"+field.Flag && n+1 < len(command) { + output := strings.TrimSuffix(filepath.Clean(command[n+1]), "/") + "/" + if strings.HasPrefix(output, input) { + return true + } + } + } + } + return false +} + func (f *roleFlow) checkDirectoryBindings(bindings map[string]string) error { for value, digest := range bindings { local, err := f.publicHostPath(strings.TrimPrefix(value, "file:")) diff --git a/cmd/relay/flow_handoff_instructions_test.go b/cmd/relay/flow_handoff_instructions_test.go index 7efc7c8..61e9d61 100644 --- a/cmd/relay/flow_handoff_instructions_test.go +++ b/cmd/relay/flow_handoff_instructions_test.go @@ -25,16 +25,13 @@ func TestPublicImportDefaultMatchesMissingStorage(t *testing.T) { } } want := "storage" - if role == "release-signer" { - want = "" - } if p.defaultPublicImport() != want { t.Fatalf("got %q want %q", p.defaultPublicImport(), want) } p.ui.input = bufio.NewReader(strings.NewReader("")) _ = p.importFile() out := p.ui.output.(*bytes.Buffer).String() - if role != "release-signer" && !strings.Contains(out, "Choose a number [4]") { + if !strings.Contains(out, "Choose a number [4]") { t.Fatal(out) } if strings.Contains(out, "folder (recommended)") { diff --git a/cmd/relay/flow_readiness.go b/cmd/relay/flow_readiness.go index 84eb753..938133e 100644 --- a/cmd/relay/flow_readiness.go +++ b/cmd/relay/flow_readiness.go @@ -20,6 +20,20 @@ type flowReadiness struct { func (f *roleFlow) readiness(task flowTask) flowReadiness { r := flowReadiness{Requirement: "Required", Source: "Relay role procedure", Status: "Ready to review inputs"} + _, applicable, policyErr := f.resolvePolicyTask(task) + if policyErr != nil { + if task.Assurance != "" { + r.Status, r.Missing = "Waiting", []string{"Authenticate the signed definition to determine whether this action applies"} + return r + } + // Dynamic defaults are resolved again before execution. Their absence + // must not make a non-optional task look disabled in a read-only menu. + applicable = true + } + if !applicable { + r.Requirement, r.Source, r.Status = "Not applicable", "signed assurance policy", "Disabled by the signed ceremony policy" + return r + } if grantDeliveryTask(task) { if f.grantDeliveryComplete(task) { if f.grantForDelivery(task) == nil { diff --git a/cmd/relay/flow_readiness_test.go b/cmd/relay/flow_readiness_test.go index 5b453fd..5fcd7ec 100644 --- a/cmd/relay/flow_readiness_test.go +++ b/cmd/relay/flow_readiness_test.go @@ -75,6 +75,40 @@ func TestTranscriptGrowthPreservesEarlierEvidenceButReplacementDoesNot(t *testin } } +func TestNestedFreshOutputMayGrowInputTreeButCannotReplaceEvidence(t *testing.T) { + f := flowFixture(t) + f.state.Profile.Work = t.TempDir() + root := filepath.Join(f.state.Profile.Work, "public") + if err := os.MkdirAll(filepath.Join(root, "operational"), 0700); err != nil { + t.Fatal(err) + } + existing := filepath.Join(root, "operational", "receipt.json") + if err := os.WriteFile(existing, []byte("signed evidence"), 0600); err != nil { + t.Fatal(err) + } + task := flowTask{Fields: []flowField{ + ff("evidence-root", "Evidence", "/work/public"), + ff("out-dir", "Output", "/work/public/operational"), + }} + command := []string{"--evidence-root", "/work/public", "--out-dir", "/work/public/operational"} + bindings, err := f.captureDirectories(task, command) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "operational", "evidence-bundle.json"), []byte("new output"), 0600); err != nil { + t.Fatal(err) + } + if err := f.checkDirectoryBindings(bindings); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(existing, []byte("changed"), 0600); err != nil { + t.Fatal(err) + } + if err := f.checkDirectoryBindings(bindings); err == nil { + t.Fatal("replacement of pre-existing evidence went unnoticed") + } +} + func TestEvidenceTreeRefusesKeysAndSymlinks(t *testing.T) { for _, kind := range []string{"key", "symlink"} { t.Run(kind, func(t *testing.T) { diff --git a/cmd/relay/flow_requirements.go b/cmd/relay/flow_requirements.go index 43d91db..d2077ea 100644 --- a/cmd/relay/flow_requirements.go +++ b/cmd/relay/flow_requirements.go @@ -91,6 +91,81 @@ func (f *roleFlow) decisionRequirement() (string, error) { } } +// resolvePolicyTask derives guidance and CLI defaults only from the +// authenticated definition. It never makes an optional control mandatory and +// never weakens proof-tool verification. +func (f *roleFlow) resolvePolicyTask(task flowTask) (flowTask, bool, error) { + needsPolicy := task.Assurance != "" || task.ID == "close" || task.ID == "ops-prepare" || + (f.state.Role == "release-signer" && task.ID == "sign") || + (f.state.Role == "coordinator" && task.ID == "verify-decision") + if !needsPolicy { + return task, true, nil + } + d, err := f.authenticatedDefinition() + if err != nil { + return task, false, err + } + requirements, err := d.RequireJourney() + if err != nil { + return task, false, err + } + count := map[string]int{ + "witness": requirements.MinimumPublicWitnesses, + "mirror": requirements.MinimumMirrorsPerAcceptedHead, + "audit": requirements.MinimumPassingCeremonyAudits, + }[task.Assurance] + if task.Assurance != "" && count == 0 { + return task, false, nil + } + for index := range task.Fields { + field := &task.Fields[index] + if task.ID == "close" && field.Flag == "beacon-round-lead" && requirements.BeaconRoundLeadSeconds > 0 { + field.Default = fmt.Sprint(requirements.BeaconRoundLeadSeconds) + field.Label = "Signed seconds from closure to the future beacon round" + } + } + // Modern proof-tool derives the exact witness quorum from the signed + // definition. An operator-controlled flag would be ambiguous and is rejected. + if task.ID == "ops-prepare" { + fields := make([]flowField, 0, len(task.Fields)) + for _, field := range task.Fields { + if field.Flag != "witness-quorum" { + fields = append(fields, field) + } + } + task.Fields = fields + } + if (f.state.Role == "release-signer" && task.ID == "sign") || + (f.state.Role == "coordinator" && task.ID == "verify-decision") { + if requirements.MinimumPassingCeremonyAudits == 0 { + fields := make([]flowField, 0, len(task.Fields)) + for _, field := range task.Fields { + if field.Flag != "audit-report" && field.Flag != "audit-signature" && + !(task.ID == "verify-decision" && field.Flag == "signature" && strings.Contains(strings.ToLower(field.Label), "auditor")) { + fields = append(fields, field) + } + } + task.Fields, task.ExtraFields, task.ExtraLabel = fields, nil, "" + } + } + return task, true, nil +} + +func (f *roleFlow) requireEnabledRole() error { + gate := map[string]string{"witness": "witness", "mirror": "mirror", "auditor": "audit"}[f.state.Role] + if gate == "" { + return nil + } + _, enabled, err := f.resolvePolicyTask(flowTask{Assurance: gate}) + if err != nil { + return fmt.Errorf("authenticate the signed definition before opening this role: %w", err) + } + if !enabled { + return fmt.Errorf("%s role is disabled by the signed ceremony assurance policy", f.state.Role) + } + return nil +} + func (f *roleFlow) checkScheduledTurns() error { phase := strings.TrimSuffix(f.stages[f.state.Stage].ID, "-turns") phase = strings.TrimSuffix(phase, "-close") diff --git a/cmd/relay/flow_requirements_test.go b/cmd/relay/flow_requirements_test.go index bca80db..92e6a4a 100644 --- a/cmd/relay/flow_requirements_test.go +++ b/cmd/relay/flow_requirements_test.go @@ -80,3 +80,83 @@ func TestHandoffWaitingDoesNotBecomeCompletion(t *testing.T) { t.Fatal("advanced waiting handoff") } } + +func policyDefinition(witnesses, mirrors, audits int, lead uint32) transcript.Definition { + j := observerTestJourney() + j.Schema = "proof-tool-mpc-definition-journey-v2" + j.MinimumPublicWitnesses = witnesses + j.MinimumMirrorsPerAcceptedHead = mirrors + j.MinimumPassingCeremonyAudits = audits + j.MinimumExternalAuditSignoffs = 0 + j.BeaconRoundLeadSeconds = lead + if audits == 0 { + filtered := j.RequiredEnrollments[:0] + for _, enrollment := range j.RequiredEnrollments { + if enrollment.Role != "auditor" { + filtered = append(filtered, enrollment) + } + } + j.RequiredEnrollments = filtered + } + return transcript.Definition{Mode: "production", Journey: j} +} + +func TestSignedAssuranceControlsGuidedTasks(t *testing.T) { + f := flowFixture(t) + f.definition = func() (transcript.Definition, error) { return policyDefinition(0, 0, 0, 17), nil } + witness := assurance(handoff("witnesses-ready", "Witnesses", "Only when enabled"), "witness") + f.stages[0].Tasks = []flowTask{witness} + if got := f.readiness(witness); got.Requirement != "Not applicable" { + t.Fatalf("disabled witness task = %#v", got) + } + if err := f.execute(witness); err == nil || !strings.Contains(err.Error(), "disabled") { + t.Fatal("disabled witness task executed", err) + } + + close := withFields(flowProof("close", "Close", "Close", "phase1", "close"), flowField{Flag: "beacon-round-lead", Default: "300", Kind: "number"}) + resolved, applicable, err := f.resolvePolicyTask(close) + if err != nil || !applicable || resolved.Fields[3].Default != "17" { + t.Fatalf("signed beacon lead not selected: %#v %v", resolved.Fields, err) + } + + ops := withFields(flowProof("ops-prepare", "Evidence", "Evidence", "ops", "prepare-bundle"), flowField{Flag: "witness-quorum", Default: "1", Kind: "number"}) + resolved, _, err = f.resolvePolicyTask(ops) + if err != nil { + t.Fatal(err) + } + for _, field := range resolved.Fields { + if field.Flag == "witness-quorum" { + t.Fatal("operator-controlled witness quorum remained") + } + } +} + +func TestZeroAuditPolicyRemovesReleaseInputsAndDisablesAuditor(t *testing.T) { + f := flowFixture(t) + f.state.Role = "release-signer" + f.definition = func() (transcript.Definition, error) { return policyDefinition(0, 0, 0, 17), nil } + var sign flowTask + for _, stage := range roleFlowStages("release-signer") { + for _, task := range stage.Tasks { + if task.ID == "sign" { + sign = task + } + } + } + resolved, applicable, err := f.resolvePolicyTask(sign) + if err != nil || !applicable { + t.Fatal(err) + } + for _, field := range resolved.Fields { + if field.Flag == "audit-report" || field.Flag == "audit-signature" { + t.Fatal("disabled audit input remained") + } + } + if len(resolved.ExtraFields) != 0 { + t.Fatal("disabled additional audit inputs remained") + } + f.state.Role = "auditor" + if err := f.requireEnabledRole(); err == nil || !strings.Contains(err.Error(), "disabled") { + t.Fatal("disabled auditor role opened", err) + } +} diff --git a/cmd/relay/main.go b/cmd/relay/main.go index 208974c..c133de7 100644 --- a/cmd/relay/main.go +++ b/cmd/relay/main.go @@ -123,6 +123,10 @@ func usage() { recovery and debugging: relay diagnostics export --work ROLE_WORK --out FRESH_ZIP + relay advanced storage-first-sync --ceremony FILE --ceremony-signature FILE \ + --coordinator-key FILE --ceremony-id SHA256 --workspace DIR \ + --proof-tool FILE --role coordinator|participant [--identity ID] \ + (--public-url HTTPS_ORIGIN | --endpoint URL --bucket NAME --profile PROFILE) relay advanced push --chain FILE --chain-signature FILE --root DIR --ceremony FILE \ --ceremony-signature FILE --coordinator-key FILE \ --bucket NAME --endpoint URL [--profile P] [--verify] @@ -153,7 +157,7 @@ transported bytes against its authenticated artifact projection. func runCoordinator(args []string) error { if len(args) == 0 { - return errors.New("coordinator requires prepare, configure-storage, grant, candidates, accept, evidence, or publish") + return errors.New("coordinator requires prepare, configure-storage, grant, candidates, accept, evidence, publish, fetch-candidate-v4, fetch-enrollment-v4, fetch-release-v4, or commit-v4") } switch args[0] { case "prepare-local": @@ -179,6 +183,14 @@ func runCoordinator(args []string) error { return runPublish(args[1:]) case "recover-initial-1828": return runRecoverInitial1828(args[1:]) + case "commit-v4": + return runCoordinatorCommitV4(args[1:]) + case "fetch-candidate-v4": + return runCoordinatorFetchCandidateV4(args[1:]) + case "fetch-enrollment-v4": + return runCoordinatorFetchEnrollmentV4(args[1:]) + case "fetch-release-v4": + return runCoordinatorFetchReleaseV4(args[1:]) default: return fmt.Errorf("unknown coordinator command %q", args[0]) } @@ -282,9 +294,11 @@ func runRelease(args []string) error { func runAdvanced(args []string) error { if len(args) == 0 { - return errors.New("advanced requires push or pull") + return errors.New("advanced requires storage-first-sync, push, pull, or verify-ceremony-pair") } switch args[0] { + case "storage-first-sync": + return runStorageFirstSync(args[1:]) case "push": return runPush(args[1:]) case "pull": diff --git a/cmd/relay/observer_setup.go b/cmd/relay/observer_setup.go index 2e0148c..b8614f2 100644 --- a/cmd/relay/observer_setup.go +++ b/cmd/relay/observer_setup.go @@ -131,7 +131,17 @@ func (f *roleFlow) prepareObserverSetup() error { if err != nil { return err } - role, err := f.ui.choose("Prepare setup instructions for", "", []setupChoice{{"witness", "Witness"}, {"mirror", "Mirror"}}) + var choices []setupChoice + if requirements.MinimumPublicWitnesses > 0 { + choices = append(choices, setupChoice{"witness", "Witness"}) + } + if requirements.MinimumMirrorsPerAcceptedHead > 0 { + choices = append(choices, setupChoice{"mirror", "Mirror"}) + } + if len(choices) == 0 { + return errors.New("witnesses and mirrors are disabled by the signed ceremony policy") + } + role, err := f.ui.choose("Prepare setup instructions for", "", choices) if err != nil { return err } @@ -260,6 +270,14 @@ func (p *rolePreparer) checkObserverSetup(s observerSetup) error { if err != nil { return err } + requirements, err := d.RequireJourney() + if err != nil { + return err + } + if (s.Role == "witness" && requirements.MinimumPublicWitnesses == 0) || + (s.Role == "mirror" && requirements.MinimumMirrorsPerAcceptedHead == 0) { + return fmt.Errorf("%s role is disabled by the signed ceremony policy", s.Role) + } after, err := setupFileHash(path) if err != nil || before != after || before != s.DefinitionSHA256 || d.CeremonyID != s.CeremonyID { return errors.New("observer setup does not match your authenticated ceremony definition") diff --git a/cmd/relay/observer_setup_test.go b/cmd/relay/observer_setup_test.go index a104739..e738a2f 100644 --- a/cmd/relay/observer_setup_test.go +++ b/cmd/relay/observer_setup_test.go @@ -19,6 +19,15 @@ func observerFixture(t *testing.T, role string) observerSetup { return observerSetup{"relay-observer-setup-v1", "ceremony", strings.Repeat("a", 64), role, 1, w.d.Identities.Coordinator} } +func observerTestJourney() *transcript.DefinitionJourney { + j := &transcript.DefinitionJourney{Schema: "proof-tool-mpc-definition-journey-v1", MinimumPublicWitnesses: 1, MinimumMirrorsPerAcceptedHead: 1, ObserverRequirementSource: "test verified requirements"} + for n, role := range []string{"coordinator", "release-signer", "auditor", "participant"} { + id := transcript.PublicIdentity{ID: role, KeyID: "key-" + role, PublicKeyFingerprint: "sha256:" + strings.Repeat(string(rune('a'+n)), 64)} + j.RequiredEnrollments = append(j.RequiredEnrollments, transcript.ExpectedEnrollment{Role: role, RoleIndex: 1, Identity: id}) + } + return j +} + func TestObserverReservationsStableAndDistinct(t *testing.T) { root := filepath.Join(t.TempDir(), "reservations") a := observerFixture(t, "witness") @@ -105,7 +114,9 @@ func TestObserverSetupImportAndAutomaticNumber(t *testing.T) { t.Fatal(err) } s.DefinitionSHA256, _ = setupFileHash(def) - p.inspectDefinition = func() (transcript.Definition, error) { return transcript.Definition{CeremonyID: s.CeremonyID}, nil } + p.inspectDefinition = func() (transcript.Definition, error) { + return transcript.Definition{CeremonyID: s.CeremonyID, Journey: observerTestJourney()}, nil + } source := filepath.Join(p.d.Work, "received-setup.json") if err := writeJSONNoReplace(source, s, 0600); err != nil { t.Fatal(err) @@ -198,7 +209,10 @@ func TestCoordinatorToObserverSetupHandoff(t *testing.T) { if err := os.WriteFile(filepath.Join(root, "ceremony.json"), definitionBytes, 0600); err != nil { t.Fatal(err) } - d := transcript.Definition{CeremonyID: "ceremony", Journey: &transcript.DefinitionJourney{Schema: "proof-tool-mpc-definition-journey-v1", MinimumPublicWitnesses: 1, MinimumMirrorsPerAcceptedHead: 1, ObserverRequirementSource: "test verified requirements"}} + d := transcript.Definition{CeremonyID: "ceremony", Journey: observerTestJourney()} + // Use the real fixed roster identities so observer-key conflicts remain + // covered while the signed policy keeps observers post-initialization. + d.Journey.RequiredEnrollments = nil for _, fixed := range []struct { role string identity setupIdentity diff --git a/cmd/relay/role.go b/cmd/relay/role.go index 72619d9..9308667 100644 --- a/cmd/relay/role.go +++ b/cmd/relay/role.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "time" @@ -31,6 +32,10 @@ type roleOpts struct { operationID string phase1Seal string phase1SealSig string + artifactRoot string + checkpoint string + checkpointSig string + attemptID string docker *dockerDriver } @@ -359,7 +364,14 @@ func runNextAt(o roleOpts, pos position, contributedAt time.Time) error { if err != nil { return err } - return writeJSONAtomic(filepath.Join(o.outDir, dockerLifecycleLogName), receipt, 0o600) + var retained dockerLifecycleReceipt + if err := setupReadJSON(filepath.Join(o.outDir, dockerLifecycleLogName), &retained); err != nil { + return err + } + if !reflect.DeepEqual(retained, *receipt) { + return errors.New("promoted lifecycle evidence differs from the completed contributor") + } + return nil } command := append([]string{o.ceremonyExecutable()}, contributionCommandArgs(o, pos, contributedAt)...) @@ -415,6 +427,14 @@ func contributionCommandArgs(o roleOpts, pos position, contributedAt time.Time) } command = append(command, "--phase1-seal", seal, "--phase1-seal-signature", sealSig) } + if o.attemptID != "" { + command = append(command, + "--artifact-root", o.artifactRoot, + "--checkpoint", o.checkpoint, + "--checkpoint-signature", o.checkpointSig, + "--attempt-id", o.attemptID, + ) + } return command } diff --git a/cmd/relay/role_flow.go b/cmd/relay/role_flow.go index fa7dc70..dbcbfe6 100644 --- a/cmd/relay/role_flow.go +++ b/cmd/relay/role_flow.go @@ -35,13 +35,16 @@ type flowField struct { } type flowTask struct { ID, Label, Help string - Command []string - Fields []flowField - ExtraFields []flowField - ExtraLabel string - Handoff bool - Optional bool - Offline bool + // Assurance names a signed policy control that makes this task applicable. + // It affects guidance only; proof-tool still enforces the authenticated policy. + Assurance string `json:"assurance,omitempty"` + Command []string + Fields []flowField + ExtraFields []flowField + ExtraLabel string + Handoff bool + Optional bool + Offline bool } type flowStage struct { ID, Label string @@ -291,12 +294,19 @@ func ensureOfflineSigningProfile(p guidedProfile, root string) error { } func saveJSONAtomic(path string, value any) error { + return saveJSONAtomicWithLimit(path, value, 1<<20) +} + +func saveJSONAtomicWithLimit(path string, value any, maximum int) error { // Private, fsynced replacement; callers hold the workflow lock. + if maximum <= 0 { + return errors.New("invalid workflow size limit") + } raw, err := json.Marshal(value) if err != nil { return err } - if len(raw) > 1<<20 { + if len(raw) > maximum { return errors.New("workflow history reached its size limit; preserve it and review archival/recovery with a maintainer before further actions") } tmp, err := os.CreateTemp(filepath.Dir(path), ".flow-*") @@ -662,6 +672,14 @@ func (f *roleFlow) execute(task flowTask) (result error) { } recordDiagnostic(c, outcome, result, f.ui.output) }() + resolved, applicable, err := f.resolvePolicyTask(task) + if err != nil { + return err + } + if !applicable { + return errors.New("this action is disabled by the signed ceremony assurance policy") + } + task = resolved if err := f.requireTaskPredecessors(task); err != nil { return err } @@ -1308,6 +1326,11 @@ func runRoleFlow(args []string) (result error) { if err := checkLauncherRelease(p.ReleaseCommit); err != nil { return err } + if v4, err := workflowV4RouteHint(p); err != nil { + return err + } else if v4 { + return runWorkflowV4Guide(p, root) + } stages := roleFlowStages(*role) if len(stages) == 0 { return errors.New("no guided workflow for this role") @@ -1447,6 +1470,9 @@ func runRoleFlow(args []string) (result error) { // completed its cleanup. CommandContext would kill it before its defers. return executeGuidedChild(open) } + if err := f.requireEnabledRole(); err != nil { + return err + } fmt.Fprintln(os.Stdout, "Saved progress is a local checklist, not authenticated ceremony state. Other people act on their own machines. Commands still verify exact signed inputs.\nUse /work for staged files, /trust for independently authenticated public keys, /keys for your own signing key. Never paste secret contents.") fmt.Fprintf(os.Stdout, "Your folders: /work = %s; /trust = %s; /keys = %s\nYou may enter host paths inside these folders; the guide maps them into Docker.\n", p.Work, p.Trust, p.Keys) return f.menu() diff --git a/cmd/relay/role_flow_catalog.go b/cmd/relay/role_flow_catalog.go index 63ae63a..f384c67 100644 --- a/cmd/relay/role_flow_catalog.go +++ b/cmd/relay/role_flow_catalog.go @@ -25,7 +25,8 @@ func withFields(task flowTask, fields ...flowField) flowTask { task.Fields = append(task.Fields, fields...) return task } -func optional(task flowTask) flowTask { task.Optional = true; return task } +func optional(task flowTask) flowTask { task.Optional = true; return task } +func assurance(task flowTask, control string) flowTask { task.Assurance = control; return task } func inspectFlow() flowTask { return withFields(flowProof("inspect", "Authenticate and inspect retained state", "Authenticates discovered state for read-only review. This does not replay contribution mathematics or authorize signing.", "inspect"), ff("transcript-dir", "Transcript directory", "/work/ceremony/public")) } @@ -148,7 +149,9 @@ func coordinatorFlowStages() []flowStage { publishBeacon := publishFlow(phase, true) publishBeacon.ID = "publish-beacon" publishBeacon.Label = "Publish the recorded beacon for mirrors and auditors" - stages = append(stages, flowStage{ID: phase + "-close", Label: phase + " closure and public beacon", Tasks: []flowTask{handoff("witnesses-ready", "Confirm witnesses are observing", "Ask each witness to confirm readiness through your coordination channel."), closeTask, publishFlow(phase, true), handoff("observations", "Collect the public-witness and beacon-relay evidence", "Witnesses must preserve exact public closure bytes and sign only what they observed before the beacon window ends. Wait for the committed round and collect distinct relay responses. Missing or conflicting evidence requires investigation."), beacon, publishBeacon}}) + witnessesReady := assurance(handoff("witnesses-ready", "Confirm witnesses are observing", "Ask each required witness to confirm readiness through your coordination channel."), "witness") + observations := assurance(handoff("observations", "Collect the public-witness evidence", "Witnesses must preserve exact public closure bytes and sign only what they observed before the beacon window ends. Missing or conflicting evidence requires investigation."), "witness") + stages = append(stages, flowStage{ID: phase + "-close", Label: phase + " closure and public beacon", Tasks: []flowTask{witnessesReady, closeTask, publishFlow(phase, true), observations, beacon, publishBeacon}}) if phase == "phase1" { seal := withFields(flowProof("seal", "Seal Phase 1 with its verified beacon", "This independently replays Phase 1 and applies its authenticated beacon.", "phase1", "seal"), ff("transcript-dir", "Phase 1 transcript", "/work/ceremony/public"), ff("closure", "Phase 1 closure", "/work/ceremony/public/phase1/closure/record.json"), ff("closure-signature", "Closure signature", "/work/ceremony/public/phase1/closure/record.sig"), ff("beacon", "Phase 1 beacon record", "/work/ceremony/public/phase1/beacon/record.json"), ff("beacon-signature", "Beacon signature", "/work/ceremony/public/phase1/beacon/record.sig"), ff("coordinator-signing-key", "Your coordinator signing key", "/keys/signing.hex"), ff("out-dir", "Fresh sealed output directory", "/work/ceremony/public/phase1/sealed")) init := withFields(flowProof("phase2-init", "Initialize circuit-specific Phase 2", "Use the exact verified Phase 1 seal and compiled circuit.", "phase2", "init"), ff("phase1-transcript-dir", "Phase 1 transcript", "/work/ceremony/public")) @@ -165,10 +168,10 @@ func coordinatorFlowStages() []flowStage { complete := withFields(flowProof("complete", "Verify public proof evidence and create the candidate", "The proof tool replays both phases again and validates the external public proof. Do not supply private witness inputs.", "finalize", "complete"), replayFields()...) complete.Fields = append(complete.Fields, ff("coordinator-signing-key", "Your coordinator signing key", "/keys/signing.hex"), ff("public-evidence", "Canonical external public proof evidence", "/work/public-finalization-evidence.json"), nowField("finalized-at"), ff("out-dir", "Fresh candidate directory", "/work/candidate")) tinyProof := flowTask{ID: "tiny-public-proof", Label: "Generate public proof evidence for the tiny rehearsal circuit", Help: "Only for rehearsal-tiny-v1. This authenticates preliminary keys and generates a real proof of the fixed public golden input. Production circuits use their matching public-evidence process.", Command: []string{"mpc-ceremony", "finalize", "rehearsal-evidence"}, Fields: []flowField{ff("keys-dir", "Authenticated preliminary keys", "/work/preliminary"), ff("coordinator-public-key-file", "Trusted coordinator key", "/trust/setup-coordinator.hex"), ft("ceremony-id", "Exact signed ceremony ID", ""), ff("out", "Fresh public proof evidence", "/work/public-finalization-evidence.json")}, Optional: true} - stages = append(stages, flowStage{ID: "finalization", Label: "Finalize and request independent audits", Tasks: []flowTask{prepare, tinyProof, handoff("public-proof", "Obtain public finalization evidence", "Give the preliminary keys to the circuit's public-evidence generation process. It returns the public proof artifact. This guide does not generate private application inputs."), complete, handoff("audits", "Send the exact candidate for independent audits", "Each enrolled auditor independently acquires and replays the complete transcript. Collect signed passing reports, signed operational evidence, and resolve incidents before requesting final signing.")}}) + audits := assurance(handoff("audits", "Send the exact candidate for independent audits", "Each required enrolled auditor independently acquires and replays the complete transcript. Collect signed passing reports and resolve incidents before requesting final signing."), "audit") + stages = append(stages, flowStage{ID: "finalization", Label: "Finalize and prepare review", Tasks: []flowTask{prepare, tinyProof, handoff("public-proof", "Obtain public finalization evidence", "Give the preliminary keys to the circuit's public-evidence generation process. It returns the public proof artifact. This guide does not generate private application inputs."), complete, audits}}) prepareOps := withFields(flowProof("ops-prepare", "Collect evidence and show what is missing", "Select the public evidence folder, never your keys or credentials folder. The tool discovers existing records and reports missing signatures, handoffs, witnesses or mirror receipts by phase and participant turn. Obtain original public records from their owners and preserve their referenced relative paths. Retry after collecting missing records; never recreate an expired observation. Only a complete, verified collection produces an unsigned bundle. Use the operational folder inside that evidence root: existing receipts are preserved, but existing bundle, signature or signing-request files must be inspected before retrying.", "ops", "prepare-bundle"), ff("evidence-root", "Public evidence folder", "/work/ceremony/public"), ff("out-dir", "Evidence folder's operational directory (existing receipts are preserved)", "/work/ceremony/public/operational")) signOps := withFields(flowProof("ops-sign", "Review and sign YOUR assembled evidence bundle", "This is the coordinator's signature on the bundle, not signatures on behalf of other roles. Every referenced record is verified again before your key is read. The final signed bundle still needs verification.", "ops", "sign", "--reviewed"), ft("record-type", "Operational record type", "evidence-bundle"), ff("record", "Prepared public evidence bundle", "/work/ceremony/public/operational/evidence-bundle.json"), ff("evidence-root", "Complete public evidence root", "/work/ceremony/public"), ff("signing-key", "Your coordinator key", "/keys/signing.hex"), ff("out", "Fresh bundle signature", "/work/ceremony/public/operational/evidence-bundle.sig")) - prepareOps.Fields = append(prepareOps.Fields, flowField{Flag: "witness-quorum", Label: "Agreed minimum witnesses per phase (1-32; do not lower it to fit available receipts)", Default: "1", Kind: "number"}) signOps.Fields = append(signOps.Fields, ft("reviewed-sha256", "SHA-256 of the exact bundle you reviewed", "")) signOps.Offline = true stages = append(stages, flowStage{ID: "operational-evidence", Label: "Collect, prepare and sign the evidence bundle", Tasks: []flowTask{prepareOps, signOps}}) @@ -236,7 +239,11 @@ func baseRoleFlowStages(role string) []flowStage { stages = append(stages, flowStage{ID: "audit", Label: "Acquire, replay and submit", Tasks: []flowTask{flowTask{ID: "sync", Label: "Synchronize from your reviewed mirror", Help: "Retain source evidence. Synchronization alone is not a full audit.", Command: []string{"relay", "auditor", "run"}, Fields: []flowField{profileField("auditor", "phase2")}}, audit, submitFlow("auditor", "phase2")}}) case "release-signer": stages[0].Tasks = append(stages[0].Tasks, handoff("offline", "Confirm the signing machine is disconnected", "Preload the approved image. Disconnect the machine before review/signing; --network=none alone does not disconnect the host. Do not bring storage credentials onto it.")) - sign := withFields(flowProof("sign", "Verify audits/evidence and sign the final parameters", "Requires at least one enrolled auditor and the signed operational bundle for both phases. This never mutates the candidate.", "release", "sign"), ff("candidate-bundle", "Exact audited candidate", "/work/candidate")) + sign := withFields(flowProof("sign", "Verify required evidence and sign the final parameters", "Uses the exact audit count selected by the signed ceremony policy and the signed operational bundle for both phases. This never mutates the candidate.", "release", "sign"), ff("candidate-bundle", "Exact reviewed candidate", "/work/candidate")) + // Release signing independently replays the exact retained ceremony. The + // proof-tool deliberately requires these explicit inputs even when the + // signed policy sets the ceremony-audit minimum to zero. + sign.Fields = append(sign.Fields, replayFields()...) sign.Fields = append(sign.Fields, ff("audit-report", "Signed audit report", ""), ff("audit-signature", "Matching audit signature", "")) sign.Fields = append(sign.Fields, ff("operational-evidence-root", "Complete operational evidence root", "/work/ceremony/public"), ff("operational-bundle", "Signed operational bundle", "/work/ceremony/public/operational/evidence-bundle.json"), ff("operational-bundle-signature", "Operational bundle signature", "/work/ceremony/public/operational/evidence-bundle.sig"), ff("release-signing-key", "Your own final-parameter signing key", "/keys/signing.hex"), ft("signature-key-id", "Your enrolled signing key ID", ""), nowField("released-at"), ff("release-dir", "Fresh signed release output", "/work/release")) sign.ExtraLabel = "Number of additional audit pairs" diff --git a/cmd/relay/role_flow_docker_test.go b/cmd/relay/role_flow_docker_test.go index e55a318..f56fd95 100644 --- a/cmd/relay/role_flow_docker_test.go +++ b/cmd/relay/role_flow_docker_test.go @@ -213,7 +213,11 @@ func TestRoleFlowDockerFullCeremony(t *testing.T) { if err := os.WriteFile(filepath.Join(trust, "release-public-key.hex"), []byte(roster.ReleaseSigner.PublicKey), 0600); err != nil { t.Fatal(err) } - f := roleFlow{state: roleFlowState{Schema: roleFlowSchema, Name: "docker-full-test", Role: "coordinator", Values: map[string]string{}}, stages: coordinatorFlowStages(), path: filepath.Join(work, "flow-state.json"), ui: coordinatorWizard{output: os.Stdout}} + f := roleFlow{state: roleFlowState{ + Schema: roleFlowSchema, Name: "docker-full-test", Role: "coordinator", + Profile: guidedProfile{Work: work, Trust: trust, Image: online, Platform: platform}, + Values: map[string]string{"shared/coordinator-public-key-file": "/trust/coordinator-public-key.hex"}, + }, stages: coordinatorFlowStages(), path: filepath.Join(work, "flow-state.json"), ui: coordinatorWizard{output: os.Stdout}} find := func(role, stageID, taskID string) flowTask { t.Helper() for index, stage := range roleFlowStages(role) { @@ -233,6 +237,13 @@ func TestRoleFlowDockerFullCeremony(t *testing.T) { t.Helper() f.stages, f.state.Role = roleFlowStages(role), role task := find(role, stage, id) + resolved, applicable, err := f.resolvePolicyTask(task) + if err != nil { + t.Fatalf("%s/%s/%s policy: %v", role, stage, id, err) + } + if !applicable { + t.Fatalf("%s/%s/%s is disabled by the fixture policy", role, stage, id) + } // This integration invokes selected cryptographic recipes directly; its // same-host fixture performs the intervening synchronization, publication, // and human handoffs outside roleFlow.execute. Record those fixture steps @@ -259,10 +270,10 @@ func TestRoleFlowDockerFullCeremony(t *testing.T) { } used := map[string]int{} var input strings.Builder - if len(task.ExtraFields) > 0 { + if len(resolved.ExtraFields) > 0 { input.WriteString("0\n") } - for _, field := range task.Fields { + for _, field := range resolved.Fields { value := field.Default if options := values[field.Flag]; len(options) > 0 { n := used[field.Flag] @@ -520,7 +531,11 @@ func TestRoleFlowDockerFullCeremony(t *testing.T) { reviewedSHA := fmt.Sprintf("%x", sha256.Sum256(bytes.TrimSpace(reviewedBundle))) execute("coordinator", "coordinator", "operational-evidence", "ops-sign", map[string][]string{"record": {preparedBundle + ".json"}, "out": {preparedBundle + ".sig"}, "reviewed-sha256": {reviewedSHA}}) execute("coordinator", "coordinator", "release", "ops-verify", map[string][]string{"record": {preparedBundle + ".json"}, "signature": {preparedBundle + ".sig"}}) - execute("release-signer", "release-signer", "sign", "sign", map[string][]string{"audit-report": {"/work/auditor-01.json", "/work/auditor-02.json"}, "audit-signature": {"/work/auditor-01.sig", "/work/auditor-02.sig"}, "signature-key-id": {roster.ReleaseSigner.KeyID}, "operational-bundle": {preparedBundle + ".json"}, "operational-bundle-signature": {preparedBundle + ".sig"}}) + releaseInputs := map[string][]string{"audit-report": {"/work/auditor-01.json", "/work/auditor-02.json"}, "audit-signature": {"/work/auditor-01.sig", "/work/auditor-02.sig"}, "signature-key-id": {roster.ReleaseSigner.KeyID}, "operational-bundle": {preparedBundle + ".json"}, "operational-bundle-signature": {preparedBundle + ".sig"}} + for key, values := range replay { + releaseInputs[key] = values + } + execute("release-signer", "release-signer", "sign", "sign", releaseInputs) execute("coordinator", "coordinator", "release", "release-verify", map[string][]string{"signature-key-id": {roster.ReleaseSigner.KeyID}}) if binary := os.Getenv("RELAY_VERIFY_MPC_BINARY"); binary != "" { publicTrust := filepath.Join(work, "verification-trust") diff --git a/cmd/relay/role_flow_test.go b/cmd/relay/role_flow_test.go index 23e617e..5a64da5 100644 --- a/cmd/relay/role_flow_test.go +++ b/cmd/relay/role_flow_test.go @@ -850,6 +850,17 @@ func TestReleaseFlowRequiresOneAuditAndAllowsAdditionalPairs(t *testing.T) { if counts["audit-report"] != 1 || counts["audit-signature"] != 1 { t.Fatalf("mandatory audit pairs: %v", counts) } + for _, flag := range []string{ + "transcript-root", "phase1-chain", "phase1-chain-signature", + "phase1-close", "phase1-close-signature", "phase1-beacon", + "phase1-beacon-signature", "phase1-seal", "phase1-seal-signature", + "phase2-chain", "phase2-chain-signature", "phase2-close", + "phase2-close-signature", "phase2-beacon", "phase2-beacon-signature", + } { + if counts[flag] != 1 { + t.Fatalf("release signing replay field %q count = %d", flag, counts[flag]) + } + } if len(task.ExtraFields) != 2 || task.ExtraFields[0].Flag != "audit-report" || task.ExtraFields[1].Flag != "audit-signature" { t.Fatal("additional audit pairs unavailable") } diff --git a/cmd/relay/role_prepare.go b/cmd/relay/role_prepare.go index 61fd765..827d941 100644 --- a/cmd/relay/role_prepare.go +++ b/cmd/relay/role_prepare.go @@ -323,7 +323,7 @@ func (p *rolePreparer) defaultPublicImport() string { return "ceremony-set" } } - if _, err := os.Lstat(preparationDestination(p.d, "storage")); p.d.Role != "release-signer" && errors.Is(err, os.ErrNotExist) { + if _, err := os.Lstat(preparationDestination(p.d, "storage")); errors.Is(err, os.ErrNotExist) { return "storage" } return "" // No missing-file recommendation; existence is not authentication. @@ -534,10 +534,10 @@ func (p *rolePreparer) nextPreparationAction() rolePreparationNext { if p.d.Role != "upload-station" && !p.historicalOnboarding() && !p.publicHandoffReported("enrollment") { return rolePreparationNext{"10", "Send your public enrollment folder to the coordinator", "Send my-enrollment, including its signature and disclosure. Report sending separately; coordinator verification and acceptance are not implied."} } + if err := p.requirePublicStorage(); err != nil { + return rolePreparationNext{"3", "Get public storage settings from your coordinator and import them", err.Error()} + } if p.d.Role != "release-signer" { - if err := p.requirePublicStorage(); err != nil { - return rolePreparationNext{"3", "Get public storage settings from your coordinator and import them", err.Error()} - } role := p.d.Role if role == "upload-station" { role = "release" diff --git a/cmd/relay/storage_first_sync.go b/cmd/relay/storage_first_sync.go new file mode 100644 index 0000000..77a61b6 --- /dev/null +++ b/cmd/relay/storage_first_sync.go @@ -0,0 +1,245 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +type storageFirstSyncFunc func(storagefirst.ObjectStore, storagefirst.Verifier, storagefirst.HighWater, string, string) (storagefirst.Snapshot, error) +type storageFirstDefinitionFunc func(transcript.Inspector) (transcript.Definition, error) + +type storageFirstSyncOptions struct { + ceremony string + ceremonySig string + coordinatorKey string + ceremonyID string + endpoint string + bucket string + profile string + region string + publicURL string + workspace string + proofTool string + role string + identity string +} + +func runStorageFirstSync(args []string) error { + return runStorageFirstSyncWith(args, os.Stdout, storagefirst.Sync, func(inspector transcript.Inspector) (transcript.Definition, error) { + return inspector.Definition() + }) +} + +func runStorageFirstSyncWith(args []string, output io.Writer, syncFn storageFirstSyncFunc, definitionFn storageFirstDefinitionFunc) error { + set := flag.NewFlagSet("advanced storage-first-sync", flag.ContinueOnError) + var options storageFirstSyncOptions + set.StringVar(&options.ceremony, "ceremony", "", "signed ceremony definition") + set.StringVar(&options.ceremonySig, "ceremony-signature", "", "detached ceremony-definition signature") + set.StringVar(&options.coordinatorKey, "coordinator-key", "", "independently authenticated coordinator public key") + set.StringVar(&options.ceremonyID, "ceremony-id", "", "expected tagged ceremony SHA-256 ID") + set.StringVar(&options.endpoint, "endpoint", "", "authenticated S3-compatible endpoint") + set.StringVar(&options.bucket, "bucket", "", "published ceremony bucket") + set.StringVar(&options.profile, "profile", "", "protected AWS CLI profile for published storage") + set.StringVar(&options.region, "region", "", "optional storage region") + set.StringVar(&options.publicURL, "public-url", "", "public HTTPS origin serving ceremony objects") + set.StringVar(&options.workspace, "workspace", "", "this role's persistent workspace") + set.StringVar(&options.proofTool, "proof-tool", "", "approved mpc-ceremony executable") + set.StringVar(&options.role, "role", "", "coordinator or participant") + set.StringVar(&options.identity, "identity", "", "participant identity ID; required for participant role") + if err := set.Parse(args); err != nil { + return err + } + if len(set.Args()) != 0 { + return errors.New("unexpected advanced storage-first-sync arguments") + } + if syncFn == nil || definitionFn == nil || output == nil { + return errors.New("storage-first synchronizer, definition verifier, and output are required") + } + if err := options.validate(); err != nil { + return err + } + + inspector := transcript.Inspector{ + Executable: options.proofTool, + CeremonyPath: options.ceremony, + CeremonySignaturePath: options.ceremonySig, + CoordinatorPublicKeyPath: options.coordinatorKey, + } + definition, err := definitionFn(inspector) + if err != nil { + return fmt.Errorf("authenticate ceremony definition: %w", err) + } + if definition.CeremonyID != options.ceremonyID { + return errors.New("--ceremony-id does not match the proof-tool-authenticated ceremony definition") + } + highWater, err := state.OpenWorkspaceHighWater(options.workspace, options.ceremonyID) + if err != nil { + return fmt.Errorf("open workspace checkpoint high-water: %w", err) + } + objects := store.Client{ + Profile: options.profile, Endpoint: options.endpoint, Region: options.region, + Bucket: options.bucket, PublicBaseURL: options.publicURL, + } + verifier := storagefirst.ProofToolVerifier{Inspector: inspector} + snapshot, err := syncFn(objects, verifier, highWater, options.ceremonyID, options.workspace) + if err != nil { + return fmt.Errorf("authenticate storage-first ceremony state: %w", err) + } + printStorageFirstSnapshot(output, snapshot, options.role, options.identity) + return nil +} + +func (o storageFirstSyncOptions) validate() error { + paths := []struct{ label, value string }{ + {"--ceremony", o.ceremony}, {"--ceremony-signature", o.ceremonySig}, + {"--coordinator-key", o.coordinatorKey}, {"--workspace", o.workspace}, + {"--proof-tool", o.proofTool}, + } + for _, item := range paths { + label, value := item.label, item.value + if value == "" { + return fmt.Errorf("%s is required", label) + } + if !filepath.IsAbs(value) || filepath.Clean(value) != value { + return fmt.Errorf("%s must be an absolute clean path", label) + } + } + files := []struct{ label, path string }{ + {"--ceremony", o.ceremony}, {"--ceremony-signature", o.ceremonySig}, + {"--coordinator-key", o.coordinatorKey}, {"--proof-tool", o.proofTool}, + } + for _, item := range files { + label, path := item.label, item.path + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s must be a regular non-symlink file", label) + } + } + if !canonicalStorageFirstDigest(o.ceremonyID) { + return errors.New("--ceremony-id must be a canonical tagged SHA-256 digest") + } + if o.role != string(storagefirst.Coordinator) && o.role != string(storagefirst.Participant) { + return errors.New("--role must be coordinator or participant") + } + if o.role == string(storagefirst.Participant) { + if o.identity == "" || len(o.identity) > 512 || strings.ContainsAny(o.identity, "\x00\r\n") { + return errors.New("--identity is required and must be a safe participant identity ID") + } + } else if o.identity != "" { + return errors.New("--identity applies only to participant role") + } + + usingPublic := o.publicURL != "" + usingAuthenticated := o.endpoint != "" || o.bucket != "" || o.profile != "" || o.region != "" + if usingPublic == usingAuthenticated { + return errors.New("choose exactly one storage source: --public-url, or --endpoint with --bucket and --profile") + } + if usingPublic { + if err := validateStorageFirstOrigin("--public-url", o.publicURL); err != nil { + return err + } + } else { + if o.endpoint == "" || o.bucket == "" || o.profile == "" { + return errors.New("authenticated storage requires --endpoint, --bucket, and --profile") + } + if err := validateStorageFirstOrigin("--endpoint", o.endpoint); err != nil { + return err + } + if len(o.bucket) > 255 || strings.ContainsAny(o.bucket, "\x00\r\n") { + return errors.New("--bucket is invalid") + } + if len(o.profile) > 255 || strings.ContainsAny(o.profile, "\x00\r\n") { + return errors.New("--profile is invalid") + } + } + return nil +} + +func validateStorageFirstOrigin(label, value string) error { + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("%s must be an HTTPS origin without credentials, query, or fragment", label) + } + if parsed.Path != "" && parsed.Path != "/" { + return fmt.Errorf("%s must not contain a path", label) + } + return nil +} + +func canonicalStorageFirstDigest(value string) bool { + if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 { + return false + } + for _, c := range strings.TrimPrefix(value, "sha256:") { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +func printStorageFirstSnapshot(output io.Writer, snapshot storagefirst.Snapshot, role, identity string) { + stage := snapshot.Checkpoint.Transition + if snapshot.Checkpoint.Position.Sequence == 0 && stage == "" { + stage = "initial" + } + head := snapshot.Checkpoint.Position.PhaseHeads["phase1"] + fmt.Fprintln(output, "authenticated storage-first ceremony state") + fmt.Fprintf(output, "sequence: %d\n", snapshot.Checkpoint.Position.Sequence) + fmt.Fprintf(output, "stage: %s\n", stage) + fmt.Fprintf(output, "phase1 accepted: %d\n", snapshot.Checkpoint.Phase1Accepted) + fmt.Fprintf(output, "phase1 head: %s\n", head.Digest) + if phase2, ok := snapshot.Checkpoint.Position.PhaseHeads["phase2"]; ok { + fmt.Fprintf(output, "phase2 accepted: %d\n", snapshot.Checkpoint.Phase2Accepted) + fmt.Fprintf(output, "phase2 head: %s\n", phase2.Digest) + } + if snapshot.Checkpoint.FinalReleaseRecorded { + fmt.Fprintln(output, "final release: recorded and authenticated") + } else if snapshot.Checkpoint.FinalCandidateRecorded { + fmt.Fprintln(output, "final candidate: recorded and authenticated") + } + + slots := append([]storagefirst.Slot(nil), snapshot.Checkpoint.Slots...) + sort.Slice(slots, func(i, j int) bool { + if slots[i].Phase != slots[j].Phase { + return slots[i].Phase < slots[j].Phase + } + if slots[i].Index != slots[j].Index { + return slots[i].Index < slots[j].Index + } + if slots[i].Kind != slots[j].Kind { + return slots[i].Kind < slots[j].Kind + } + return slots[i].AttemptID < slots[j].AttemptID + }) + printed := 0 + for _, slot := range slots { + if role == string(storagefirst.Participant) && slot.IdentityID != identity { + continue + } + if printed == 0 { + fmt.Fprintln(output, "relevant submission slots:") + } + fmt.Fprintf(output, " %s %s/%d identity=%s attempt=%s status=%s manifest=%s\n", + slot.Kind, slot.Phase, slot.Index, slot.IdentityID, slot.AttemptID, slot.Status, slot.ManifestKey) + printed++ + } + if printed == 0 { + fmt.Fprintln(output, "relevant submission slots: none") + } +} diff --git a/cmd/relay/storage_first_sync_test.go b/cmd/relay/storage_first_sync_test.go new file mode 100644 index 0000000..a846017 --- /dev/null +++ b/cmd/relay/storage_first_sync_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +func storageFirstSyncFiles(t *testing.T) (string, string, string, string, string) { + t.Helper() + root := t.TempDir() + paths := []string{ + filepath.Join(root, "ceremony.json"), filepath.Join(root, "ceremony.sig"), + filepath.Join(root, "coordinator.hex"), filepath.Join(root, "mpc-ceremony"), + } + for _, path := range paths { + if err := os.WriteFile(path, []byte("test"), 0o700); err != nil { + t.Fatal(err) + } + } + return paths[0], paths[1], paths[2], paths[3], filepath.Join(root, "workspace") +} + +func storageFirstSyncBaseArgs(t *testing.T) []string { + t.Helper() + ceremony, signature, key, proofTool, workspace := storageFirstSyncFiles(t) + return []string{ + "--ceremony", ceremony, "--ceremony-signature", signature, + "--coordinator-key", key, "--ceremony-id", "sha256:" + strings.Repeat("1", 64), + "--workspace", workspace, "--proof-tool", proofTool, + "--role", "participant", "--identity", "participant-1", + "--public-url", "https://ceremony.example", + } +} + +func acceptStorageFirstDefinition(inspector transcript.Inspector) (transcript.Definition, error) { + return transcript.Definition{CeremonyID: "sha256:" + strings.Repeat("1", 64)}, nil +} + +func TestAdvancedStorageFirstSyncPrintsOnlyRoleRelevantAuthenticatedSlots(t *testing.T) { + args := storageFirstSyncBaseArgs(t) + var output bytes.Buffer + called := false + syncFn := func(objects storagefirst.ObjectStore, verifier storagefirst.Verifier, highWater storagefirst.HighWater, ceremonyID, tempParent string) (storagefirst.Snapshot, error) { + called = true + client, ok := objects.(store.Client) + if !ok || client.PublicBaseURL != "https://ceremony.example" || client.Bucket != "" || client.NoSign { + t.Fatalf("unexpected public client: %#v", objects) + } + if _, ok := verifier.(storagefirst.ProofToolVerifier); !ok { + t.Fatalf("unexpected verifier: %T", verifier) + } + if ceremonyID != "sha256:"+strings.Repeat("1", 64) || tempParent != args[9] { + t.Fatalf("unexpected sync scope: %q %q", ceremonyID, tempParent) + } + position := state.CheckpointPosition{Sequence: 2, Digest: "sha256:" + strings.Repeat("2", 64), PhaseHeads: map[string]state.PhaseHeadPosition{"phase1": {Index: 0, Digest: "sha256:" + strings.Repeat("a", 64)}}} + return storagefirst.Snapshot{Checkpoint: storagefirst.Checkpoint{ + Position: position, Transition: "phase1-outbound-published", ParticipantID: "participant-1", + Slots: []storagefirst.Slot{ + {Kind: "receipt", Phase: "phase1", Index: 1, IdentityID: "participant-1", AttemptID: "mine", Status: "pending", ManifestKey: "mine/manifest.json"}, + {Kind: "receipt", Phase: "phase1", Index: 2, IdentityID: "participant-2", AttemptID: "other", Status: "pending", ManifestKey: "other/manifest.json"}, + }, + }}, nil + } + definitionFn := func(inspector transcript.Inspector) (transcript.Definition, error) { + if inspector.Executable != args[11] || inspector.CeremonyPath != args[1] || inspector.CeremonySignaturePath != args[3] || inspector.CoordinatorPublicKeyPath != args[5] { + t.Fatalf("unexpected proof-tool inspector: %+v", inspector) + } + return acceptStorageFirstDefinition(inspector) + } + if err := runStorageFirstSyncWith(args, &output, syncFn, definitionFn); err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("synchronizer was not called") + } + text := output.String() + for _, want := range []string{"authenticated storage-first ceremony state", "sequence: 2", "stage: phase1-outbound-published", "attempt=mine"} { + if !strings.Contains(text, want) { + t.Fatalf("output missing %q:\n%s", want, text) + } + } + if strings.Contains(text, "participant-2") || strings.Contains(text, "attempt=other") { + t.Fatalf("participant saw another role's slot:\n%s", text) + } +} + +func TestAdvancedStorageFirstSyncPrintsAuthenticatedPhase2AndFinalState(t *testing.T) { + args := storageFirstSyncBaseArgs(t) + args[13] = "coordinator" + args = append(args[:14], args[16:]...) + var output bytes.Buffer + syncFn := func(storagefirst.ObjectStore, storagefirst.Verifier, storagefirst.HighWater, string, string) (storagefirst.Snapshot, error) { + return storagefirst.Snapshot{Checkpoint: storagefirst.Checkpoint{ + Position: state.CheckpointPosition{Sequence: 14, Digest: "sha256:" + strings.Repeat("2", 64), PhaseHeads: map[string]state.PhaseHeadPosition{ + "phase1": {Index: 1, Digest: "sha256:" + strings.Repeat("a", 64), Closed: true}, + "phase2": {Index: 1, Digest: "sha256:" + strings.Repeat("b", 64), Closed: true}, + }}, Transition: "final-release-recorded", Phase1Accepted: 1, Phase2Accepted: 1, FinalCandidateRecorded: true, FinalReleaseRecorded: true, + }}, nil + } + if err := runStorageFirstSyncWith(args, &output, syncFn, acceptStorageFirstDefinition); err != nil { + t.Fatal(err) + } + for _, want := range []string{"phase2 accepted: 1", "phase2 head: sha256:" + strings.Repeat("b", 64), "final release: recorded and authenticated"} { + if !strings.Contains(output.String(), want) { + t.Fatalf("output missing %q:\n%s", want, output.String()) + } + } +} + +func TestAdvancedStorageFirstSyncBuildsAuthenticatedStorageClient(t *testing.T) { + args := storageFirstSyncBaseArgs(t) + args = args[:len(args)-2] + args = append(args, "--endpoint", "https://storage.example", "--bucket", "published", "--profile", "relay", "--region", "auto") + syncFn := func(objects storagefirst.ObjectStore, _ storagefirst.Verifier, _ storagefirst.HighWater, _, _ string) (storagefirst.Snapshot, error) { + client := objects.(store.Client) + if client.Endpoint != "https://storage.example" || client.Bucket != "published" || client.Profile != "relay" || client.Region != "auto" || client.PublicBaseURL != "" { + t.Fatalf("unexpected authenticated client: %+v", client) + } + return storagefirst.Snapshot{Checkpoint: storagefirst.Checkpoint{Position: state.CheckpointPosition{Digest: "sha256:" + strings.Repeat("2", 64), PhaseHeads: map[string]state.PhaseHeadPosition{"phase1": {Digest: "sha256:" + strings.Repeat("a", 64)}}}}}, nil + } + if err := runStorageFirstSyncWith(args, &bytes.Buffer{}, syncFn, acceptStorageFirstDefinition); err != nil { + t.Fatal(err) + } +} + +func TestAdvancedStorageFirstSyncPrintsNothingWhenAuthenticationFails(t *testing.T) { + var output bytes.Buffer + err := runStorageFirstSyncWith(storageFirstSyncBaseArgs(t), &output, func(storagefirst.ObjectStore, storagefirst.Verifier, storagefirst.HighWater, string, string) (storagefirst.Snapshot, error) { + return storagefirst.Snapshot{}, errors.New("bad checkpoint signature") + }, acceptStorageFirstDefinition) + if err == nil || !strings.Contains(err.Error(), "bad checkpoint signature") { + t.Fatalf("err=%v", err) + } + if output.Len() != 0 { + t.Fatalf("unauthenticated output was printed: %q", output.String()) + } +} + +func TestAdvancedStorageFirstSyncRequiresOneStorageSourceAndRole(t *testing.T) { + base := storageFirstSyncBaseArgs(t) + for name, mutate := range map[string]func([]string) []string{ + "both sources": func(args []string) []string { + return append(args, "--endpoint", "https://storage.example", "--bucket", "bucket", "--profile", "profile") + }, + "missing participant identity": func(args []string) []string { + for i := range args { + if args[i] == "--identity" { + return append(args[:i], args[i+2:]...) + } + } + return args + }, + } { + t.Run(name, func(t *testing.T) { + args := mutate(append([]string(nil), base...)) + if err := runStorageFirstSyncWith(args, &bytes.Buffer{}, storagefirst.Sync, acceptStorageFirstDefinition); err == nil { + t.Fatal("invalid command accepted") + } + }) + } +} + +func TestAdvancedDispatchRecognizesStorageFirstSync(t *testing.T) { + err := runAdvanced([]string{"storage-first-sync"}) + if err == nil || !strings.Contains(err.Error(), "--ceremony is required") { + t.Fatalf("storage-first-sync was not dispatched to its flag validation: %v", err) + } +} + +func TestAdvancedStorageFirstSyncRejectsCeremonyIDMismatchBeforeSync(t *testing.T) { + called := false + err := runStorageFirstSyncWith(storageFirstSyncBaseArgs(t), &bytes.Buffer{}, func(storagefirst.ObjectStore, storagefirst.Verifier, storagefirst.HighWater, string, string) (storagefirst.Snapshot, error) { + called = true + return storagefirst.Snapshot{}, nil + }, func(transcript.Inspector) (transcript.Definition, error) { + return transcript.Definition{CeremonyID: "sha256:" + strings.Repeat("2", 64)}, nil + }) + if err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("err=%v", err) + } + if called { + t.Fatal("storage sync ran for a mismatched ceremony definition") + } +} diff --git a/cmd/relay/tessera.go b/cmd/relay/tessera.go index fd7abac..a3ca8fd 100644 --- a/cmd/relay/tessera.go +++ b/cmd/relay/tessera.go @@ -20,6 +20,8 @@ import ( "regexp" "strings" "time" + + setupv3 "github.com/zksecurity/relay/contracts/setupv3" ) const tesseraSignRequestSchema = "tessera-sign-request-v1" @@ -56,17 +58,19 @@ type tesseraCapabilities struct { func runTessera(args []string) error { if len(args) == 0 { - return errors.New("tessera requires capabilities, complete-setup, verify-setup, release-manifest, export-setup or confirm") + return errors.New("tessera requires capabilities, complete-setup, verify-setup, release-manifest-v3, export-setup or confirm") } switch args[0] { case "release-manifest": - return runSetupManifest(args[1:]) + return errors.New("new releases create setup v3 manifests; existing setup v2 ceremonies must use their pinned older Relay launcher") + case "release-manifest-v3": + return runSetupManifestV3(args[1:]) case "storage-credentials": return runTesseraStorageCredentials(args[1:]) case "complete-setup": - return runSetupV2(args[1:], true) + return runSetupBySchema(args[1:], true) case "verify-setup": - return runSetupV2(args[1:], false) + return runSetupBySchema(args[1:], false) case "capabilities": return runTesseraCapabilities(args[1:]) case "confirm": @@ -91,10 +95,45 @@ func runTesseraCapabilities(args []string) error { if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(commit) { return errors.New("Tessera capabilities require an attested Relay release build with its source commit") } - result := tesseraCapabilities{Schema: tesseraCapabilitiesSchema, RelayCommit: commit, FileSchemas: []string{tesseraConnectionSchema, tesseraRoleConnectionSchema, "ceremony-setup-v2", "ceremony-software-manifest-v2", "tessera-bundle-v1", "tessera-draft-context-v1", tesseraSignRequestSchema, tesseraSignResponseSchema}, SigningPurposes: []string{"tessera:assignment-confirmation:v1", "tessera:account-recovery:v1"}} + result := tesseraCapabilities{Schema: tesseraCapabilitiesSchema, RelayCommit: commit, FileSchemas: []string{tesseraConnectionSchema, tesseraRoleConnectionSchema, "ceremony-setup-v3", "ceremony-software-manifest-v3", "tessera-bundle-v1", "tessera-draft-context-v1", tesseraSignRequestSchema, tesseraSignResponseSchema}, SigningPurposes: []string{"tessera:assignment-confirmation:v1", "tessera:account-recovery:v1"}} return json.NewEncoder(os.Stdout).Encode(result) } +func runSetupBySchema(args []string, complete bool) error { + setupPath := "" + for i := 0; i < len(args); i++ { + if args[i] == "--setup" && i+1 < len(args) { + setupPath = args[i+1] + break + } + if strings.HasPrefix(args[i], "--setup=") { + setupPath = strings.TrimPrefix(args[i], "--setup=") + break + } + } + if setupPath == "" { + return errors.New("--setup is required") + } + raw, err := readTesseraRegularFile(setupPath, setupv3.MaxBytes, false) + if err != nil { + return err + } + var header struct { + Schema string `json:"schema"` + } + if err := json.Unmarshal(raw, &header); err != nil { + return fmt.Errorf("setup: %w", err) + } + switch header.Schema { + case "ceremony-setup-v2": + return runSetupV2(args, complete) + case "ceremony-setup-v3": + return runSetupV3(args, complete) + default: + return fmt.Errorf("setup schema %q is unsupported", header.Schema) + } +} + func runTesseraConfirm(args []string) error { set := flag.NewFlagSet("tessera confirm", flag.ContinueOnError) requestPath := set.String("request", "", "Tessera signing request") diff --git a/cmd/relay/tessera_export.go b/cmd/relay/tessera_export.go index e9cc26a..694c282 100644 --- a/cmd/relay/tessera_export.go +++ b/cmd/relay/tessera_export.go @@ -288,12 +288,16 @@ type tesseraToolDigest struct { } func checkTesseraDefinition(c tesseraContext, raw []byte, inspected transcript.Definition) (tesseraDefinition, error) { + return checkTesseraDefinitionSchema(c, raw, inspected, "proof-tool-mpc-ceremony-definition-v2") +} + +func checkTesseraDefinitionSchema(c tesseraContext, raw []byte, inspected transcript.Definition, expectedSchema string) (tesseraDefinition, error) { var d tesseraDefinition // Unknown signed protocol fields are retained in the original artifact. if err := json.Unmarshal(raw, &d); err != nil { return d, err } - if d.Schema != "proof-tool-mpc-ceremony-definition-v2" || d.ProtocolID != inspected.CeremonyID || !tesseraHash.MatchString(d.ProtocolID) || d.Mode != c.Mode || d.Mode != inspected.Mode || !slices.Equal(d.Phase1.Participants, inspected.Phase1Participants) || !slices.Equal(d.Phase2.Participants, inspected.Phase2Participants) { + if d.Schema != expectedSchema || d.ProtocolID != inspected.CeremonyID || !tesseraHash.MatchString(d.ProtocolID) || d.Mode != c.Mode || d.Mode != inspected.Mode || !slices.Equal(d.Phase1.Participants, inspected.Phase1Participants) || !slices.Equal(d.Phase2.Participants, inspected.Phase2Participants) { return d, errors.New("signed definition and authenticated inspection disagree with the website roster") } roster, p1, p2 := c.setupInputs() diff --git a/cmd/relay/tessera_handoff.go b/cmd/relay/tessera_handoff.go index 1e2a220..203cb68 100644 --- a/cmd/relay/tessera_handoff.go +++ b/cmd/relay/tessera_handoff.go @@ -11,7 +11,7 @@ import ( // This receipt is local navigation state, never website acceptance evidence. func (w *coordinatorWizard) tesseraExportPresent() bool { - if w.d.Tessera == nil && w.d.TesseraSetup == nil { + if w.d.Tessera == nil && w.d.TesseraSetup == nil && w.d.TesseraSetupV3 == nil { return false } if w.d.TesseraExportPath == "" || w.d.TesseraExportSHA256 == "" { diff --git a/cmd/relay/tessera_prepare.go b/cmd/relay/tessera_prepare.go index 8fdcc4e..28a20db 100644 --- a/cmd/relay/tessera_prepare.go +++ b/cmd/relay/tessera_prepare.go @@ -5,13 +5,16 @@ import ( "encoding/json" "errors" "fmt" - setupv2 "github.com/zksecurity/relay/contracts/setupv2r2" + setupv3 "github.com/zksecurity/relay/contracts/setupv3" "os" "path/filepath" "strings" ) func checkTesseraDraft(d coordinatorDraft) error { + if d.TesseraSetupV3 != nil { + return checkSetupDraftV3(d) + } if d.TesseraSetup != nil { return checkSetupDraftV2(d) } @@ -37,7 +40,7 @@ func (w *coordinatorWizard) importTesseraRoster() error { if err != nil { return err } - raw, err := readTesseraRegularFile(path, setupv2.MaxBytes, false) + raw, err := readTesseraRegularFile(path, setupv3.MaxBytes, false) if err != nil { return err } @@ -53,6 +56,9 @@ func (w *coordinatorWizard) importTesseraRoster() error { if header.Schema == "ceremony-setup-v2" { return w.importSetupV2(path, raw) } + if header.Schema == "ceremony-setup-v3" { + return w.importSetupV3(path, raw) + } c, err := loadTesseraContext(path) if err != nil { return err @@ -86,6 +92,9 @@ func (w *coordinatorWizard) exportTesseraSetup() error { if w.d.TesseraSetup != nil { return w.exportSetupV2() } + if w.d.TesseraSetupV3 != nil { + return w.exportSetupV3() + } storage := tesseraStorage{Provider: w.d.Storage["provider"], Region: w.d.Storage["region"], PublicURL: w.d.Storage["published-base-url"], PublishedBucket: w.d.Storage["published-bucket"], InboxBucket: w.d.Storage["inbox-bucket"]} if err := storage.validate(); err != nil { return err diff --git a/cmd/relay/tessera_storage.go b/cmd/relay/tessera_storage.go index 818bb72..24c6ad1 100644 --- a/cmd/relay/tessera_storage.go +++ b/cmd/relay/tessera_storage.go @@ -152,21 +152,30 @@ func runTesseraStorageCredentials(args []string) error { return json.NewEncoder(os.Stdout).Encode(result) } func (c tesseraStorageConnection) matchesDraft(d coordinatorDraft) error { - if d.TesseraSetup == nil { + var ceremonyID string + var expectedRegion, expectedPublished, expectedInbox, expectedURL string + if d.TesseraSetupV3 != nil { + ceremonyID = d.TesseraSetupV3.ID + expected := d.TesseraSetupV3.Plan.Storage + expectedRegion, expectedPublished, expectedInbox, expectedURL = expected.Region, expected.PublishedBucket, expected.InboxBucket, expected.PublicBaseURL + } else if d.TesseraSetup != nil { + ceremonyID = d.TesseraSetup.ID + expected := d.TesseraSetup.Plan.Storage + expectedRegion, expectedPublished, expectedInbox, expectedURL = expected.Region, expected.PublishedBucket, expected.InboxBucket, expected.PublicBaseURL + } else { return errors.New("open the setup downloaded from Tessera first") } - if c.CeremonyID != d.TesseraSetup.ID || c.CoordinatorFingerprint != d.Identities.Coordinator.Fingerprint { + if c.CeremonyID != ceremonyID || c.CoordinatorFingerprint != d.Identities.Coordinator.Fingerprint { return errors.New("connection belongs to a different ceremony or coordinator key") } - expected := d.TesseraSetup.Plan.Storage s := c.Settings.Settings - if s["region"] != expected.Region || s["published-bucket"] != expected.PublishedBucket || s["inbox-bucket"] != expected.InboxBucket || s["published-base-url"] != expected.PublicBaseURL { + if s["region"] != expectedRegion || s["published-bucket"] != expectedPublished || s["inbox-bucket"] != expectedInbox || s["published-base-url"] != expectedURL { return errors.New("connection storage differs from the imported setup") } return nil } func (w *coordinatorWizard) connectTesseraStorage() error { - if w.d.TesseraSetup == nil { + if w.d.TesseraSetup == nil && w.d.TesseraSetupV3 == nil { return errors.New("open the setup downloaded from Tessera before connecting storage") } path, err := w.required("Private CLI connection JSON downloaded from Tessera, absolute path", "") diff --git a/cmd/relay/tessera_v2.go b/cmd/relay/tessera_v2.go index 1bdbc3f..3b741e9 100644 --- a/cmd/relay/tessera_v2.go +++ b/cmd/relay/tessera_v2.go @@ -99,6 +99,7 @@ func (w *coordinatorWizard) importSetupV2(path string, raw []byte) error { } previous := w.d w.d.Tessera = nil + w.d.TesseraSetupV3 = nil w.d.TesseraSetup = s w.d.Mode = s.Plan.Mode w.d.Circuit = s.Plan.Circuit diff --git a/cmd/relay/tessera_v3.go b/cmd/relay/tessera_v3.go new file mode 100644 index 0000000..5bc6a41 --- /dev/null +++ b/cmd/relay/tessera_v3.go @@ -0,0 +1,416 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + + setupv3 "github.com/zksecurity/relay/contracts/setupv3" + "github.com/zksecurity/relay/internal/transcript" + releaseassets "github.com/zksecurity/relay/release" +) + +type setupReleaseManifestV3 struct { + Schema string `json:"schema"` + ReleaseTag string `json:"release_tag"` + CLICommit string `json:"cli_commit"` + ProofToolCommit string `json:"proof_tool_commit"` + ContractSHA256 string `json:"contract_sha256"` + Ruleset setupv3.Ruleset `json:"ruleset"` + RoleImages json.RawMessage `json:"role_images"` + Inputs json.RawMessage `json:"role_image_inputs"` + Recipe json.RawMessage `json:"workflow_recipe"` +} + +func setupRecipeDigestV3() string { + var recipe any + if err := json.Unmarshal(tesseraRecipe(), &recipe); err != nil { + panic(err) + } + raw, err := setupv3.Canonical(recipe) + if err != nil { + panic(err) + } + return setupv3.Hash(raw) +} + +func checkSetupDraftV3(d coordinatorDraft) error { + s := d.TesseraSetupV3 + if s == nil { + return errors.New("open the website setup first") + } + if s.Result != nil { + return errors.New("completed setup is review only") + } + if err := s.Validate(); err != nil { + return err + } + c := setupContextV3(*s) + roster, p1, p2 := c.setupInputs() + expected, _ := setupv3.Canonical([]any{s.Plan.Mode, s.Plan.Circuit, s.Plan.SoftwareRelease.ReleaseTag, roster, p1, p2, s.Plan.BeaconPolicy, s.Plan.AssurancePolicy, s.Plan.Storage}) + storage := setupv3.Storage{Provider: d.Storage["provider"], Region: d.Storage["region"], PublicBaseURL: d.Storage["published-base-url"], PublishedBucket: d.Storage["published-bucket"], InboxBucket: d.Storage["inbox-bucket"]} + actual, _ := setupv3.Canonical([]any{d.Mode, d.Circuit, d.Release, d.Identities, d.Policy.Phase1, d.Policy.Phase2, d.Policy.Beacon, d.Policy.Assurance, storage}) + if !bytes.Equal(actual, expected) { + return errors.New("public setup changed locally; edit it in Tessera and open the updated download before initializing") + } + return nil +} +func (w *coordinatorWizard) importSetupV3(path string, raw []byte) error { + s, err := setupv3.Parse(raw) + if err != nil { + return err + } + if s.Result != nil { + fmt.Fprintln(w.output, "This setup already contains a signed result. Verifying it for review; it will not initialize or sign again.") + return runSetupV3([]string{"--setup", path}, false) + } + if err = checkLauncherRelease(s.Plan.SoftwareRelease.CLICommit); err != nil { + return err + } + // Authenticate release metadata before accepting the plan, rather than waiting + // until after a potentially expensive initialization to discover a mismatch. + _, _, images, err := verifiedReleaseImageMap(s.Plan.SoftwareRelease.ReleaseTag, "coordinator", "linux/"+runtime.GOARCH) + if err != nil { + return err + } + manifest, err := setupManifestV3(s.Plan.SoftwareRelease.CLICommit, images) + if err != nil { + return err + } + if setupv3.Hash(manifest) != s.Plan.SoftwareRelease.ManifestSHA256 || setupRecipeDigestV3() != s.Plan.SoftwareRelease.WorkflowRecipeSHA256 { + return errors.New("website selected a different software manifest or workflow") + } + fmt.Fprintf(w.output, "Website setup %s revision %d\nMode: %s\nCircuit: %s\nRelease: %s\nPublic storage: %s\n", s.ID, s.PlanRevision, s.Plan.Mode, s.Plan.Circuit, s.Plan.SoftwareRelease.ReleaseTag, s.Plan.Storage.PublicBaseURL) + for _, i := range s.Plan.Identities { + fmt.Fprintf(w.output, "%q · %s\n", i.DisplayName, i.Fingerprint) + } + fmt.Fprintln(w.output, "This imports all public settings. Private key and credential paths remain local. Compare public fingerprints with their owners.") + if err = w.confirm("Accept this setup", "IMPORT SETUP"); err != nil { + return err + } + previous := w.d + w.d.Tessera = nil + w.d.TesseraSetup = nil + w.d.TesseraSetupV3 = s + w.d.Mode = s.Plan.Mode + w.d.Circuit = s.Plan.Circuit + w.d.Release = s.Plan.SoftwareRelease.ReleaseTag + w.d.Identities, w.d.Policy.Phase1, w.d.Policy.Phase2 = setupContextV3(*s).setupInputs() + b, _ := json.Marshal(s.Plan.BeaconPolicy) + if err = json.Unmarshal(b, &w.d.Policy.Beacon); err != nil { + w.d = previous + return err + } + w.d.Policy.Assurance = &setupAssurance{ + PublicWitnessesPerPhase: uint8(s.Plan.AssurancePolicy.PublicWitnessesPerPhase), + MirrorsPerAcceptedHead: uint8(s.Plan.AssurancePolicy.MirrorsPerAcceptedHead), + PassingCeremonyAudits: uint8(s.Plan.AssurancePolicy.PassingCeremonyAudits), + ExternalSecurityAuditSignoffs: uint8(s.Plan.AssurancePolicy.ExternalSecurityAuditSignoffs), + } + // Copy the map so a failed save can restore the prior draft without aliases. + storage := map[string]string{} + for k, v := range w.d.Storage { + storage[k] = v + } + p := s.Plan.Storage + storage["provider"] = p.Provider + storage["region"] = p.Region + storage["published-base-url"] = p.PublicBaseURL + storage["published-bucket"] = p.PublishedBucket + storage["inbox-bucket"] = p.InboxBucket + w.d.Storage = storage + if err = w.save(); err != nil { + w.d = previous + return err + } + w.message(toneSuccess, "Setup imported. Review the draft and approve initialization. After verification, export the completed setup for Tessera.\n") + return nil +} +func (w *coordinatorWizard) exportSetupV3() error { + out, err := w.required("Fresh completed setup JSON path", w.tesseraExportDefault()) + if err != nil { + return err + } + dir, err := os.MkdirTemp("", "setup-input-v3-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "setup.json") + if err = setupWriteNew(path, w.d.TesseraSetupV3); err != nil { + return err + } + err = runSetupV3([]string{"--setup", path, "--out", out, "--ceremony", filepath.Join(w.d.Work, "ceremony/public/ceremony.json"), "--ceremony-signature", filepath.Join(w.d.Work, "ceremony/public/ceremony.sig"), "--coordinator-key-file", filepath.Join(w.d.Trust, "setup-coordinator.hex")}, true) + if err != nil { + return err + } + return w.recordTesseraExport(out) +} + +func setupManifestV3(commit string, images []byte) ([]byte, error) { + for _, platform := range []string{"linux/amd64", "linux/arm64"} { + if _, err := selectReleaseImage(images, commit, "coordinator", platform); err != nil { + return nil, err + } + } + var pins struct { + MPC map[string]struct { + URL string `json:"url"` + } `json:"mpc"` + } + if err := json.Unmarshal(releaseassets.RoleImageInputs(), &pins); err != nil { + return nil, err + } + proof := "" + for _, p := range pins.MPC { + parts := strings.Split(p.URL, "mpc-ci-") + if len(parts) != 2 { + return nil, errors.New("invalid embedded proof-tool release") + } + c := strings.Split(parts[1], "/")[0] + if proof != "" && proof != c { + return nil, errors.New("mixed embedded proof-tool commits") + } + proof = c + } + m := setupReleaseManifestV3{"ceremony-software-manifest-v3", "role-images-" + commit, commit, proof, setupv3.Hash(setupv3.SchemaJSON), setupv3.Rules(), images, releaseassets.RoleImageInputs(), tesseraRecipe()} + return setupv3.Canonical(m) +} +func runSetupManifestV3(args []string) error { + f := flag.NewFlagSet("tessera release-manifest", flag.ContinueOnError) + path := f.String("role-images", "", "attested CI role image map") + out := f.String("out", "", "fresh software manifest output") + if err := f.Parse(args); err != nil { + return err + } + if *path == "" || *out == "" || f.NArg() != 0 { + return errors.New("--role-images and --out are required") + } + commit := launcherCommit() + if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(commit) { + return errors.New("release build required") + } + raw, err := readTesseraRegularFile(*path, 1<<20, false) + if err != nil { + return err + } + manifest, err := setupManifestV3(commit, raw) + if err != nil { + return err + } + return writeTesseraFresh(*out, manifest, 0600) +} + +// Only an internal adapter for the existing protocol projection checker; this +// shape is never emitted or accepted as a v3 transport file. +func setupContextV3(s setupv3.Setup) tesseraContext { + c := tesseraContext{Schema: "tessera-draft-context-v1", CeremonyID: s.ID, Revision: s.PlanRevision, Mode: s.Plan.Mode, Assignments: []tesseraAssignment{}, Schedules: []tesseraSchedule{}} + ids := map[string]setupIdentity{} + roleIDs := map[string]string{} + for _, i := range s.Plan.Identities { + ids[i.ID] = setupIdentity{i.ID, i.DisplayName, i.KeyID, i.PublicKey, i.Fingerprint} + } + for _, r := range s.Plan.Roles { + phases := []string{} + for _, p := range s.Plan.Phases { + for _, id := range p.IdentityIDs { + if id == r.IdentityID { + phases = append(phases, p.ID) + } + } + } + c.Assignments = append(c.Assignments, tesseraAssignment{r.ID, r.ID, 1, r.Role, phases, ids[r.IdentityID]}) + roleIDs[r.IdentityID] = r.ID + } + for _, p := range s.Plan.Phases { + a := []string{} + for _, id := range p.IdentityIDs { + a = append(a, roleIDs[id]) + } + c.Schedules = append(c.Schedules, tesseraSchedule{p.ID, a, p.Minimum}) + } + return c +} +func checkSetupDefinitionV3(s setupv3.Setup, definition, key, manifest []byte, inspected transcript.Definition) error { + d, err := checkTesseraDefinitionSchema(setupContextV3(s), definition, inspected, "proof-tool-mpc-ceremony-definition-v3") + if err != nil { + return err + } + var public struct { + Circuit struct { + KeyVersion string `json:"key_version"` + } `json:"circuit"` + Beacon map[string]any `json:"beacon_policy"` + Assurance setupv3.AssurancePolicy `json:"assurance_policy"` + } + if err = json.Unmarshal(definition, &public); err != nil { + return err + } + actual, _ := setupv3.Canonical(public.Beacon) + expected, _ := setupv3.Canonical(s.Plan.BeaconPolicy) + if public.Circuit.KeyVersion != s.Plan.Circuit || !bytes.Equal(actual, expected) || public.Assurance != s.Plan.AssurancePolicy { + return errors.New("signed circuit, beacon or assurance policy differs from website plan") + } + if strings.TrimSpace(string(key)) != d.Coordinator.PublicKey { + return errors.New("coordinator public key differs from website plan") + } + var m setupReleaseManifestV3 + if err = tesseraJSON(manifest, &m); err != nil { + return err + } + expectedManifest, err := setupManifestV3(s.Plan.SoftwareRelease.CLICommit, m.RoleImages) + if err != nil { + return err + } + if !bytes.Equal(manifest, expectedManifest) || setupv3.Hash(manifest) != s.Plan.SoftwareRelease.ManifestSHA256 || m.ProofToolCommit != s.Plan.SoftwareRelease.ProofToolCommit || m.ProofToolCommit != d.Software.Commit || setupRecipeDigestV3() != s.Plan.SoftwareRelease.WorkflowRecipeSHA256 { + return errors.New("setup software differs from this approved CLI release") + } + // Reuse the pinned native binary checks, including every allowed architecture. + _, err = tesseraManifest(d, m.ReleaseTag, m.RoleImages) + return err +} +func inspectSetupV3(definition, signature, key []byte, image, platform string) (transcript.Definition, error) { + dir, err := os.MkdirTemp("", "setup-v3-") + if err != nil { + return transcript.Definition{}, err + } + defer os.RemoveAll(dir) + for name, raw := range map[string][]byte{"ceremony.json": definition, "ceremony.sig": signature, "coordinator.hex": key} { + if err = os.WriteFile(filepath.Join(dir, name), raw, 0600); err != nil { + return transcript.Definition{}, err + } + } + inspector := transcript.Inspector{Executable: "mpc-ceremony", CeremonyPath: "/input/ceremony.json", CeremonySignaturePath: "/input/ceremony.sig", CoordinatorPublicKeyPath: "/input/coordinator.hex", Runner: func(_ string, args ...string) ([]byte, []byte, error) { + argv := []string{"run", "--rm", "--pull=never", "--network=none", "--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--user", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), "--platform", platform, "--mount", "type=bind,src=" + dir + ",dst=/input,readonly", "--entrypoint", "/usr/local/bin/mpc-ceremony", image} + cmd := exec.Command("docker", append(argv, args...)...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.Bytes(), stderr.Bytes(), err + }} + return inspector.Definition() +} + +func runSetupV3(args []string, complete bool) error { + f := flag.NewFlagSet("tessera setup-v3", flag.ContinueOnError) + path := f.String("setup", "", "website setup JSON") + out := f.String("out", "", "fresh output JSON (completion only)") + definitionPath := f.String("ceremony", "", "signed definition") + signaturePath := f.String("ceremony-signature", "", "definition signature") + keyPath := f.String("coordinator-key-file", "", "public coordinator key") + manifestPath := f.String("trusted-manifest", "", "locally provisioned, independently attested release manifest; otherwise retrieve from GitHub") + platform := f.String("platform", "linux/"+runtime.GOARCH, "approved Linux inspection platform") + if err := f.Parse(args); err != nil { + return err + } + if *path == "" || f.NArg() != 0 { + return errors.New("--setup is required") + } + raw, err := readTesseraRegularFile(*path, setupv3.MaxBytes, false) + if err != nil { + return err + } + s, err := setupv3.Parse(raw) + if err != nil { + return err + } + if err = checkLauncherRelease(s.Plan.SoftwareRelease.CLICommit); err != nil { + return err + } + if complete && s.Result != nil { + return errors.New("setup already contains a result; use verify-setup to review it, without initializing or signing again") + } + if !complete && s.Result == nil { + return errors.New("setup has no signed result to verify") + } + var manifest []byte + var image string + if *manifestPath != "" { + manifest, err = readTesseraRegularFile(*manifestPath, 1<<20, false) + if err != nil { + return err + } + var m setupReleaseManifestV3 + if err = tesseraJSON(manifest, &m); err != nil { + return err + } + image, err = selectReleaseImage(m.RoleImages, s.Plan.SoftwareRelease.CLICommit, "coordinator", *platform) + } else { + var images []byte + image, _, images, err = verifiedReleaseImageMap(s.Plan.SoftwareRelease.ReleaseTag, "coordinator", *platform) + if err == nil { + manifest, err = setupManifestV3(s.Plan.SoftwareRelease.CLICommit, images) + } + } + if err != nil { + return err + } + if setupv3.Hash(manifest) != s.Plan.SoftwareRelease.ManifestSHA256 { + return errors.New("release manifest does not match downloaded setup") + } + artifacts := map[string][]byte{"software-manifest": manifest} + if complete { + if *out == "" || *definitionPath == "" || *signaturePath == "" || *keyPath == "" { + return errors.New("completion requires --out, --ceremony, --ceremony-signature and --coordinator-key-file") + } + for kind, path := range map[string]string{"definition": *definitionPath, "definition-signature": *signaturePath, "coordinator-key": *keyPath} { + artifacts[kind], err = readTesseraRegularFile(path, 1<<20, false) + if err != nil { + return err + } + } + } else { + for _, a := range s.Result.Artifacts { + if a.Platform == "none" { + artifacts[a.Kind], _ = base64.StdEncoding.DecodeString(a.ContentB64) + } + } + if !bytes.Equal(artifacts["software-manifest"], manifest) { + return errors.New("artifact manifest differs from trusted release") + } + } + inspected, err := inspectSetupV3(artifacts["definition"], artifacts["definition-signature"], artifacts["coordinator-key"], image, *platform) + if err != nil { + return err + } + if err = checkSetupDefinitionV3(*s, artifacts["definition"], artifacts["coordinator-key"], manifest, inspected); err != nil { + return err + } + if complete { + input, err := s.InputDigest() + if err != nil { + return err + } + s.Result = &setupv3.Result{InputSHA256: input, ProtocolID: inspected.CeremonyID, Artifacts: []setupv3.Artifact{}} + for _, kind := range []string{"definition", "definition-signature", "coordinator-key", "software-manifest"} { + b := artifacts[kind] + s.Result.Artifacts = append(s.Result.Artifacts, setupv3.Artifact{Kind: kind, Platform: "none", SHA256: setupv3.Hash(b), ByteLength: len(b), ContentB64: base64.StdEncoding.EncodeToString(b)}) + } + if err = s.Validate(); err != nil { + return err + } + raw, err = json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + if err = writeTesseraFresh(*out, append(raw, '\n'), 0600); err != nil { + return err + } + } else if s.Result.ProtocolID != inspected.CeremonyID { + return errors.New("result protocol ID differs from authenticated definition") + } + input, _ := s.InputDigest() + sha, _ := s.Digest() + result, _ := s.Result.Digest() + return json.NewEncoder(os.Stdout).Encode(map[string]any{"schema": "ceremony-setup-verification-v3", "setup_sha256": sha, "input_sha256": input, "result_sha256": result, "protocol_id": s.Result.ProtocolID, "signature_verified": true, "software_verified": true}) +} diff --git a/cmd/relay/tessera_v3_test.go b/cmd/relay/tessera_v3_test.go new file mode 100644 index 0000000..dc51585 --- /dev/null +++ b/cmd/relay/tessera_v3_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + setupv3 "github.com/zksecurity/relay/contracts/setupv3" +) + +func setupTestManifestV3(t *testing.T) []byte { + t.Helper() + commit := strings.Repeat("c", 40) + images := []map[string]string{} + for target, name := range map[string]string{"online": "relay-role-online", "offline": "relay-role-offline", "contributor": "relay-ceremony-tool"} { + for _, platform := range []string{"linux/amd64", "linux/arm64"} { + images = append(images, map[string]string{"target": target, "platform": platform, "source_commit": commit, "image": "ghcr.io/zksecurity/relay/" + name + "@sha256:" + strings.Repeat("b", 64)}) + } + } + sort.Slice(images, func(i, j int) bool { + return images[i]["target"]+images[i]["platform"] < images[j]["target"]+images[j]["platform"] + }) + raw, _ := json.Marshal(map[string]any{"schema": "relay-role-image-release/v1", "approval": "github-attested-ci", "source_commit": commit, "launcher_commit": commit, "images": images}) + m, err := setupManifestV3(commit, raw) + if err != nil { + t.Fatal(err) + } + return m +} + +func setupTestPlanV3(t *testing.T, c tesseraContext, manifest []byte) setupv3.Setup { + t.Helper() + var m setupReleaseManifestV3 + if err := json.Unmarshal(manifest, &m); err != nil { + t.Fatal(err) + } + s := setupv3.Setup{Schema: "ceremony-setup-v3", ID: c.CeremonyID, PlanRevision: c.Revision, Plan: setupv3.Plan{ + Mode: c.Mode, Ruleset: setupv3.Rules(), Circuit: "rehearsal-tiny-v1", + BeaconPolicy: setupv3.Beacon(c.Mode, 12), + AssurancePolicy: setupv3.AssurancePolicy{PassingCeremonyAudits: 1}, + SoftwareRelease: setupv3.Software{ReleaseTag: m.ReleaseTag, CLICommit: m.CLICommit, ProofToolCommit: m.ProofToolCommit, ManifestSHA256: setupv3.Hash(manifest), WorkflowRecipeSHA256: setupRecipeDigestV3()}, + Storage: setupv3.Storage{Provider: "aws", Region: "us-east-1", PublicBaseURL: "https://public.example.test", PublishedBucket: "published-test", InboxBucket: "inbox-test"}, + }} + ids := map[string]string{} + for _, a := range c.Assignments { + i := a.Identity + s.Plan.Identities = append(s.Plan.Identities, setupv3.Identity{ID: i.ID, DisplayName: i.DisplayName, KeyID: i.KeyID, PublicKey: i.PublicKey, Fingerprint: i.Fingerprint}) + s.Plan.Roles = append(s.Plan.Roles, setupv3.Role{ID: a.ID, Role: a.Role, IdentityID: i.ID}) + ids[a.ID] = i.ID + } + for _, p := range c.Schedules { + phase := setupv3.Phase{ID: p.Phase, Minimum: p.Minimum} + for _, id := range p.IDs { + phase.IdentityIDs = append(phase.IdentityIDs, ids[id]) + } + s.Plan.Phases = append(s.Plan.Phases, phase) + } + if err := s.Validate(); err != nil { + t.Fatal(err) + } + return s +} + +func TestSetupV3PlanDrift(t *testing.T) { + c, _, _ := tesseraFixture(t) + s := setupTestPlanV3(t, c, setupTestManifestV3(t)) + w := setupFixture(t) + w.d.TesseraSetupV3 = &s + w.d.Mode, w.d.Circuit, w.d.Release = s.Plan.Mode, s.Plan.Circuit, s.Plan.SoftwareRelease.ReleaseTag + w.d.Identities, w.d.Policy.Phase1, w.d.Policy.Phase2 = setupContextV3(s).setupInputs() + raw, _ := json.Marshal(s.Plan.BeaconPolicy) + if err := json.Unmarshal(raw, &w.d.Policy.Beacon); err != nil { + t.Fatal(err) + } + w.d.Policy.Assurance = &setupAssurance{PassingCeremonyAudits: 1} + p := s.Plan.Storage + w.d.Storage = map[string]string{"provider": p.Provider, "region": p.Region, "published-base-url": p.PublicBaseURL, "published-bucket": p.PublishedBucket, "inbox-bucket": p.InboxBucket} + if err := checkTesseraDraft(w.d); err != nil { + t.Fatal(err) + } + for name, change := range map[string]func(*coordinatorDraft){ + "beacon wait": func(d *coordinatorDraft) { d.Policy.Beacon.Lead++ }, + "assurance": func(d *coordinatorDraft) { d.Policy.Assurance.MirrorsPerAcceptedHead++ }, + "release": func(d *coordinatorDraft) { d.Release = "latest" }, + } { + t.Run(name, func(t *testing.T) { + d := w.d + a := *d.Policy.Assurance + d.Policy.Assurance = &a + change(&d) + if checkTesseraDraft(d) == nil { + t.Fatal("accepted local public-plan drift") + } + }) + } +} + +func TestSetupV3AcceptsAuthenticatedDefinitionV3(t *testing.T) { + c, definition, inspected := tesseraFixture(t) + definition.Schema = "proof-tool-mpc-ceremony-definition-v3" + manifest := setupTestManifestV3(t) + setup := setupTestPlanV3(t, c, manifest) + var public map[string]any + raw, _ := json.Marshal(definition) + if err := json.Unmarshal(raw, &public); err != nil { + t.Fatal(err) + } + public["circuit"] = map[string]any{"key_version": setup.Plan.Circuit} + public["beacon_policy"] = setup.Plan.BeaconPolicy + public["assurance_policy"] = setup.Plan.AssurancePolicy + raw, _ = json.Marshal(public) + if err := checkSetupDefinitionV3(setup, raw, []byte(definition.Coordinator.PublicKey), manifest, inspected); err != nil { + t.Fatalf("authenticated v3 definition rejected: %v", err) + } + public["assurance_policy"] = setupv3.AssurancePolicy{} + tampered, _ := json.Marshal(public) + if err := checkSetupDefinitionV3(setup, tampered, []byte(definition.Coordinator.PublicKey), manifest, inspected); err == nil { + t.Fatal("changed signed assurance policy accepted") + } +} + +func TestRunSetupBySchemaRejectsUnknownSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "setup.json") + if err := os.WriteFile(path, []byte(`{"schema":"ceremony-setup-v99"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := runSetupBySchema([]string{"--setup", path}, false); err == nil || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("unknown setup schema error = %v", err) + } +} diff --git a/cmd/relay/workflow_commands.go b/cmd/relay/workflow_commands.go index 0def70d..e923336 100644 --- a/cmd/relay/workflow_commands.go +++ b/cmd/relay/workflow_commands.go @@ -6,6 +6,7 @@ import ( "errors" "flag" "fmt" + "io" "io/fs" "os" "os/exec" @@ -590,6 +591,13 @@ func confirmErasure(o roleOpts) error { } func confirmDockerNoCopies(o roleOpts) error { + return confirmDockerNoCopiesWithIO(o, bufio.NewReader(os.Stdin), os.Stdout) +} + +func confirmDockerNoCopiesWithIO(o roleOpts, input *bufio.Reader, output io.Writer) error { + if input == nil || output == nil { + return errors.New("cleanup confirmation input and output are required") + } path := filepath.Join(o.outDir, dockerLifecycleLogName) raw, err := os.ReadFile(path) if err != nil { @@ -602,7 +610,7 @@ func confirmDockerNoCopies(o roleOpts) error { return errors.New("Docker lifecycle record does not contain the required cleanup checks") } shortID := shortContainerID(receipt.ContainerID) - fmt.Printf(`Contribution completed. + fmt.Fprintf(output, `Contribution completed. Relay verified: ✓ Docker daemon %s was reached through local endpoint %s @@ -624,8 +632,8 @@ Confirm that you: • did not configure the disposable environment for backup. Type CLEANUP PRECAUTIONS CONFIRMED to acknowledge these limitations and continue: `, receipt.Daemon.ID, receipt.Daemon.Endpoint, shortID, shortID) - disableTerminalFocusReporting(os.Stdout) - line, readErr := bufio.NewReader(os.Stdin).ReadString('\n') + disableTerminalFocusReporting(output) + line, readErr := input.ReadString('\n') if readErr != nil && len(line) == 0 { return readErr } diff --git a/cmd/relay/workflow_v4_command.go b/cmd/relay/workflow_v4_command.go new file mode 100644 index 0000000..1a17dd8 --- /dev/null +++ b/cmd/relay/workflow_v4_command.go @@ -0,0 +1,209 @@ +package main + +import ( + "errors" + "path/filepath" + "reflect" + "strings" + "time" +) + +// Network transfers and coordinated commits use typed in-process handlers, +// never exec of these dispatch tokens. Each handler must consume the exact +// plan. Child signing/computation argv is checked separately below. +func validateWorkflowV4Command(p workflowV4OperationPlan, b workflowV4Binding) error { + switch p.Kind { + case "download-outbound", "download-receipt", "download-candidate", "upload-receipt", "upload-candidate", "issue-grant", "commit-outbound", "commit-receipt", "commit-candidate": + if !reflect.DeepEqual(p.Command, []string{"relay-internal", p.Kind}) { + return errors.New("V4 internal action cannot execute an arbitrary command") + } + return nil + } + prefix := []string{"mpc-ceremony", "ops", "sign"} + if p.Kind == "contribute" { + prefix = []string{"mpc-ceremony", p.Scope.Phase, "contribute"} + } + if p.Kind == "attest-erasure" { + prefix = []string{"mpc-ceremony", p.Scope.Phase, "attest-erasure"} + } + if len(p.Command) < len(prefix) || !reflect.DeepEqual(p.Command[:len(prefix)], prefix) { + return errors.New("V4 command does not match its operation kind") + } + flags := make(map[string]string) + for n := len(prefix); n < len(p.Command); n++ { + flag := p.Command[n] + if !strings.HasPrefix(flag, "--") || strings.Contains(flag, "=") { + return errors.New("V4 command requires explicit named arguments") + } + if _, exists := flags[flag]; exists { + return errors.New("duplicate V4 command argument") + } + if flag == "--reviewed" { + flags[flag] = "true" + continue + } + n++ + if n == len(p.Command) || p.Command[n] == "" { + return errors.New("missing V4 command argument value") + } + flags[flag] = p.Command[n] + } + inputByFlag := []string{"--ceremony", "--ceremony-signature", "--coordinator-public-key-file"} + if p.Kind == "contribute" { + inputByFlag = append(inputByFlag, "--chain", "--chain-signature", "--environment", "--checkpoint", "--checkpoint-signature") + if p.Scope.Phase == "phase2" { + inputByFlag = append(inputByFlag, "--phase1-seal", "--phase1-seal-signature") + } + } else if p.Kind != "attest-erasure" { + inputByFlag = append(inputByFlag, "--record") + } + inputRefs := make(map[string]workflowV4Input) + for _, input := range p.Inputs { + path, err := workflowV4ContainerPath(p.Runtime, input.Path) + if err != nil { + return err + } + inputRefs[path] = input + } + allowed := make(map[string]bool) + for _, flag := range inputByFlag { + allowed[flag] = true + input, ok := inputRefs[flags[flag]] + if !ok { + return errors.New("V4 command input is not an exact retained file") + } + switch flag { + case "--ceremony": + if input.Ref != b.Definition.Record { + return errors.New("V4 command uses another definition") + } + case "--ceremony-signature": + if input.Ref != b.Definition.Signature { + return errors.New("V4 command uses another definition signature") + } + case "--chain": + if input.Ref != p.Predecessor.Record { + return errors.New("V4 command uses another predecessor") + } + case "--chain-signature": + if input.Ref != p.Predecessor.Signature { + return errors.New("V4 command uses another predecessor signature") + } + case "--checkpoint": + if input.Ref != p.Allocation.Record { + return errors.New("V4 command uses another allocation checkpoint") + } + case "--checkpoint-signature": + if input.Ref != p.Allocation.Signature { + return errors.New("V4 command uses another allocation signature") + } + } + } + if p.Kind == "attest-erasure" { + return validateWorkflowV4ErasureCommand(p, flags, allowed, inputRefs) + } + outputFlag, keyFlag := "--out", "--signing-key" + if p.Kind == "contribute" { + outputFlag, keyFlag = "--out-dir", "--participant-signing-key" + } + allowed[outputFlag], allowed[keyFlag] = true, true + if len(p.Outputs) != 1 { + return errors.New("V4 child command requires exactly one declared output") + } + output, err := workflowV4ContainerPath(p.Runtime, p.Outputs[0]) + if err != nil { + return err + } + if flags[outputFlag] != output { + return errors.New("V4 command output differs from its declared output") + } + key := flags[keyFlag] + if p.Runtime.Mounts["/keys"] == "" || key != "/keys/signing.hex" { + return errors.New("V4 child must use the role's protected signing key") + } + if p.Kind == "contribute" { + for _, flag := range []string{"--transcript-dir", "--artifact-root", "--participant-id", "--contributed-at", "--attempt-id"} { + allowed[flag] = true + } + if flags["--participant-id"] != p.Scope.ParticipantID || flags["--attempt-id"] != p.AttemptID { + return errors.New("V4 command participant differs from the turn") + } + timestamp, err := time.Parse(time.RFC3339, flags["--contributed-at"]) + if err != nil || timestamp.IsZero() || timestamp.UTC().Format(time.RFC3339) != flags["--contributed-at"] { + return errors.New("V4 contribution needs an exact UTC timestamp") + } + root := flags["--transcript-dir"] + if !strings.HasPrefix(root, "/work/") || filepath.Clean(root) != root || flags["--artifact-root"] != root { + return errors.New("V4 transcript root must be retained under work") + } + for _, flag := range []string{"--chain", "--chain-signature"} { + if _, err := pathWithin(root, flags[flag], "/"); err != nil { + return errors.New("V4 predecessor is outside the declared transcript root") + } + } + } else { + for _, flag := range []string{"--record-type", "--reviewed", "--reviewed-sha256"} { + allowed[flag] = true + } + kind := "receipt" + if p.Kind == "sign-return" { + kind = "handoff" + } + if flags["--record-type"] != kind || flags["--reviewed"] != "true" || flags["--reviewed-sha256"] != strings.TrimPrefix(inputRefs[flags["--record"]].Ref.Digest.SHA256, "sha256:") { + return errors.New("V4 signing command must bind the exact reviewed record") + } + } + for flag := range flags { + if !allowed[flag] { + return errors.New("unsupported V4 child command argument") + } + } + return nil +} + +// Cleanup signing is a separate side effect, never an implicit tail of +// computation. The executor additionally verifies the original lifecycle +// receipt and records the operator's confirmation before preparing this plan. +func validateWorkflowV4ErasureCommand(p workflowV4OperationPlan, flags map[string]string, allowed map[string]bool, inputs map[string]workflowV4Input) error { + for _, flag := range []string{"--candidate-dir", "--participant-id", "--participant-signing-key", "--destroyed-at"} { + allowed[flag] = true + } + for flag := range flags { + if !allowed[flag] { + return errors.New("unsupported V4 cleanup command argument") + } + } + if flags["--participant-id"] != p.Scope.ParticipantID || p.Runtime.Mounts["/keys"] == "" || flags["--participant-signing-key"] != "/keys/signing.hex" { + return errors.New("V4 cleanup signer differs from the participant") + } + stamp := flags["--destroyed-at"] + when, err := time.Parse(time.RFC3339, stamp) + if err != nil || when.IsZero() || when.UTC().Format(time.RFC3339) != stamp { + return errors.New("V4 cleanup needs an exact UTC timestamp") + } + root := flags["--candidate-dir"] + if !strings.HasPrefix(root, "/work/") || filepath.Clean(root) != root || len(p.Outputs) != 2 { + return errors.New("V4 cleanup requires the retained candidate and two exact outputs") + } + for _, name := range []string{"attestation.json", "attestation.sig", "contribution.bin", "relay-lifecycle.json"} { + if _, ok := inputs[filepath.Join(root, name)]; !ok { + return errors.New("V4 cleanup lacks a retained computation or lifecycle input") + } + } + for n, name := range []string{"erasure.json", "erasure.sig"} { + output, err := workflowV4ContainerPath(p.Runtime, p.Outputs[n]) + if err != nil || output != filepath.Join(root, name) { + return errors.New("V4 cleanup output differs from its declared candidate") + } + } + return nil +} + +func workflowV4ContainerPath(runtime workflowV4Runtime, path string) (string, error) { + for destination, source := range runtime.Mounts { + if mapped, err := pathWithin(source, path, destination); err == nil { + return mapped, nil + } + } + return "", errors.New("V4 command path has no approved runtime mount") +} diff --git a/cmd/relay/workflow_v4_command_test.go b/cmd/relay/workflow_v4_command_test.go new file mode 100644 index 0000000..04edc30 --- /dev/null +++ b/cmd/relay/workflow_v4_command_test.go @@ -0,0 +1,208 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWorkflowV4CommandRejectsPlanMismatch(t *testing.T) { + for _, mutation := range []string{"executable", "verb", "output", "chain", "definition", "key", "extra-flag", "duplicate-flag", "participant", "runtime", "platform", "mount", "transcript-root"} { + t.Run(mutation, func(t *testing.T) { + _, binding := workflowV4TestBinding(t) + plan := workflowV4TestPlan(t, binding) + set := func(flag, value string) { + for n := range plan.Command { + if plan.Command[n] == flag { + plan.Command[n+1] = value + return + } + } + t.Fatal("missing test flag", flag) + } + switch mutation { + case "executable": + plan.Command[0] = "sh" + case "verb": + plan.Command[2] = "init" + case "output": + set("--out-dir", "/work/other") + case "chain": + set("--chain", "/work/chain-current.json") + case "definition": + set("--ceremony", "/work/workflow-v4/inputs/"+plan.ID+"/environment.json") + case "key": + set("--participant-signing-key", "/work/key.hex") + case "extra-flag": + plan.Command = append(plan.Command, "--unexpected", "yes") + case "duplicate-flag": + plan.Command = append(plan.Command, "--out-dir", "/work/candidate") + case "participant": + set("--participant-id", "another") + case "runtime": + plan.Runtime.Image = "example.test/role@sha256:" + strings.Repeat("e", 64) + case "platform": + plan.Runtime.Platform = "linux/amd64" + case "mount": + plan.Runtime.Mounts["/trust"] = t.TempDir() + case "transcript-root": + set("--transcript-dir", "/work/unrelated") + } + if err := validateWorkflowV4Plan(plan, binding); err == nil { + t.Fatal("accepted mismatched command/profile") + } + }) + } +} + +func TestWorkflowV4CommandSigningBindsReviewedRecord(t *testing.T) { + _, binding := workflowV4TestBinding(t) + plan := workflowV4TestPlan(t, binding) + plan.Kind = "sign-return" + plan.AttemptID = "" + root := "/work/workflow-v4/inputs/" + plan.ID + record := plan.Inputs[len(plan.Inputs)-1] // synthetic canonical record for the command-binding test + plan.Outputs = []string{filepath.Join(binding.Work, "return.sig")} + plan.Command = []string{"mpc-ceremony", "ops", "sign", "--ceremony", root + "/ceremony.json", "--ceremony-signature", root + "/ceremony.sig", "--coordinator-public-key-file", "/trust/coordinator.hex", "--record-type", "handoff", "--record", root + "/environment.json", "--signing-key", "/keys/signing.hex", "--reviewed", "--reviewed-sha256", strings.TrimPrefix(record.Ref.Digest.SHA256, "sha256:"), "--out", "/work/return.sig"} + if err := validateWorkflowV4Plan(plan, binding); err != nil { + t.Fatal(err) + } + for n := range plan.Command { + if plan.Command[n] == "--reviewed-sha256" { + plan.Command[n+1] = strings.Repeat("0", 64) + } + } + if err := validateWorkflowV4Plan(plan, binding); err == nil { + t.Fatal("accepted different reviewed bytes") + } +} + +func TestWorkflowV4JournalRejectsForeignAuthorityAndReversedTime(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + wrongPair := binding.Definition + wrongPair.Record.Digest.SHA256 = commitDigest("f") + if _, err := openWorkflowV4Journal(protocol, wrongPair, binding); err == nil { + t.Fatal("accepted different authenticated definition") + } + wrong := binding + wrong.IdentityID = "not-assigned" + if _, err := openWorkflowV4Journal(protocol, binding.Definition, wrong); err == nil { + t.Fatal("accepted unassigned identity") + } + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + plan := workflowV4TestPlan(t, binding) + if err := j.prepare(plan); err != nil { + t.Fatal(err) + } + state := j.state + earlier := state.Operations[0].Prepared.Add(-time.Second) + state.Operations[0].Status = "running" + state.Operations[0].Started = &earlier + if err := validateWorkflowV4State(state, binding, j.path); err == nil { + t.Fatal("accepted reversed timestamp") + } +} + +func TestWorkflowV4JournalCoordinatorReceiptDownload(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + binding.Role, binding.IdentityID = "coordinator", "coordinator-test" + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + plan := workflowV4TestPlan(t, binding) + plan.Kind, plan.AttemptID = "download-receipt", strings.Repeat("8", 32) + plan.Command = []string{"relay-internal", "download-receipt"} + if err := j.prepare(plan); err != nil { + t.Fatal(err) + } + if err := j.runPrepared(plan.ID, func(p workflowV4OperationPlan) error { return os.WriteFile(p.Outputs[0], []byte("received"), 0600) }); err != nil { + t.Fatal(err) + } + op, err := j.pending() + if err != nil || op.Status != "returned-needs-verification" { + t.Fatal("download claimed verification", err) + } +} + +func TestWorkflowV4CommandPhase2RequiresSealInputs(t *testing.T) { + _, binding := workflowV4TestBinding(t) + plan := workflowV4TestPlan(t, binding) + plan.Scope.Phase = "phase2" + plan.Command[1] = "phase2" + if err := validateWorkflowV4Plan(plan, binding); err == nil { + t.Fatal("accepted phase2 without seal") + } + for _, item := range []struct{ flag, name string }{{"--phase1-seal", "seal.json"}, {"--phase1-seal-signature", "seal.sig"}} { + ref := workflowV4TestRef(item.name, item.name) + plan.Inputs = append(plan.Inputs, workflowV4Input{Path: filepath.Join(binding.Work, item.name), Ref: ref}) + plan.Command = append(plan.Command, item.flag, "/work/"+item.name) + } + if err := validateWorkflowV4Plan(plan, binding); err != nil { + t.Fatal(err) + } +} + +func TestWorkflowV4RuntimeRejectsOverlappingTrustAndKeys(t *testing.T) { + for _, nested := range []bool{false, true} { + t.Run(map[bool]string{false: "equal", true: "nested"}[nested], func(t *testing.T) { + _, binding := workflowV4TestBinding(t) + runtime := binding.Runtimes["signer"] + runtime.Mounts["/keys"] = runtime.Mounts["/trust"] + if nested { + runtime.Mounts["/keys"] = filepath.Join(runtime.Mounts["/trust"], "private") + } + if err := validateWorkflowV4Runtime(runtime, binding.Work); err == nil { + t.Fatal("accepted ambiguous public/private mount mapping") + } + }) + } +} + +func TestWorkflowV4CleanupCommandBindsSeparateOutputs(t *testing.T) { + _, binding := workflowV4TestBinding(t) + plan := workflowV4TestPlan(t, binding) + plan.Kind = "attest-erasure" + plan.Runtime = binding.Runtimes["signer"] + root := "/work/workflow-v4/inputs/" + plan.ID + plan.Command = []string{"mpc-ceremony", "phase1", "attest-erasure", "--ceremony", root + "/ceremony.json", "--ceremony-signature", root + "/ceremony.sig", "--coordinator-public-key-file", "/trust/coordinator.hex", "--participant-id", plan.Scope.ParticipantID, "--participant-signing-key", "/keys/signing.hex", "--candidate-dir", "/work/candidate", "--destroyed-at", "2026-09-16T00:00:00Z"} + plan.Outputs = []string{filepath.Join(binding.Work, "candidate", "erasure.json"), filepath.Join(binding.Work, "candidate", "erasure.sig")} + for _, name := range []string{"attestation.json", "attestation.sig", "contribution.bin", "relay-lifecycle.json"} { + plan.Inputs = append(plan.Inputs, workflowV4Input{Path: filepath.Join(binding.Work, "candidate", name), Ref: workflowV4TestRef(name, name)}) + } + if err := validateWorkflowV4Plan(plan, binding); err != nil { + t.Fatal(err) + } + plan.Outputs[1] = filepath.Join(binding.Work, "candidate", "attestation.sig") + if err := validateWorkflowV4Plan(plan, binding); err == nil { + t.Fatal("cleanup may overwrite computation signature") + } + plan.Outputs[1] = filepath.Join(binding.Work, "candidate", "erasure.sig") + plan.Inputs = plan.Inputs[:len(plan.Inputs)-1] + if err := validateWorkflowV4Plan(plan, binding); err == nil { + t.Fatal("cleanup accepted without lifecycle input") + } +} + +func TestWorkflowV4ContributionOptionsPreserveSavedCommand(t *testing.T) { + _, b := workflowV4TestBinding(t) + p := workflowV4TestPlan(t, b) + o, pos, when, err := workflowV4ContributionOptions(p) + if err != nil { + t.Fatal(err) + } + if o.operationID != p.ID || o.outDir != p.Outputs[0] || pos.chainPath != p.Inputs[0].Path || when.Format(time.RFC3339) != "2026-01-01T00:00:00Z" { + t.Fatal("lost saved contribution binding") + } + p.Command = append(p.Command, "--unexpected", "value") + if _, _, _, err := workflowV4ContributionOptions(p); err == nil { + t.Fatal("driver silently discarded a saved argument") + } +} diff --git a/cmd/relay/workflow_v4_contribute.go b/cmd/relay/workflow_v4_contribute.go new file mode 100644 index 0000000..8d8696f --- /dev/null +++ b/cmd/relay/workflow_v4_contribute.go @@ -0,0 +1,126 @@ +package main + +import ( + "errors" + "path/filepath" + "reflect" + "strings" + "time" + + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +// executePreparedContribution consumes the freshly synchronized turn and the +// exact saved operation. It delegates isolation/cleanup to the existing driver; +// a successful return still requires retained-output reconciliation. +func (j *workflowV4Journal) executePreparedContribution(id, dockerCLI string, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, local storagefirst.LocalTurnV4) error { + op, err := j.pending() + if err != nil { + return err + } + if op == nil || op.Plan.ID != id || op.Plan.Kind != "contribute" || op.Status != "prepared" { + return errors.New("prepared contribution required") + } + p := op.Plan + b := j.state.Marker.Binding + if err := validateWorkflowV4Plan(p, b); err != nil { + return err + } + if local.PendingOperation { + return errors.New("resolve the earlier operation before contribution") + } + r, err := snapshot.RecommendTurnV4(protocol, p.Scope.Phase, storagefirst.Participant, b.IdentityID, local, "", time.Now().UTC()) + if err != nil { + return err + } + if r.Action != "contribute" || !r.Ready || r.Scope != p.Scope || r.AttemptID != p.AttemptID { + return errors.New("authenticated turn does not authorize this saved contribution") + } + if !filepath.IsAbs(dockerCLI) || filepath.Clean(dockerCLI) != dockerCLI { + return errors.New("exact local Docker CLI path required") + } + o, pos, when, err := workflowV4ContributionOptions(p) + if err != nil { + return err + } + d := &dockerDriver{image: p.Runtime.Image, platform: p.Runtime.Platform, ceremonyBinary: "/usr/local/bin/mpc-ceremony", root: o.root, definition: o.definition, definitionSig: o.definitionSig, coordinatorKey: o.coordinatorKey, signingKey: o.signingKey, environment: o.envPath, candidateRoot: filepath.Dir(o.outDir), executionIntentPath: workflowV4ContributorIntentPath(b.Work, p.ID), client: osDockerCommandClient{binary: dockerCLI}, now: time.Now} + o.docker = d + if err := workflowV4OutputsAbsent(p); err != nil { + return err + } + if err := workflowV4InputsMatch(p); err != nil { + return err + } + d.replaceUnstartedIntent = true // The durable operation is still prepared. + d.beforeCreate = func() error { + if err := workflowV4OutputsAbsent(p); err != nil { + return err + } + if err := workflowV4InputsMatch(p); err != nil { + return err + } + return j.transition(id, "running") + } + if err := runNextAt(o, pos, when); err != nil { + if errors.Is(err, errContributorNotCreated) { + if transitionErr := j.transition(id, "failed-no-effects"); transitionErr != nil { + return errors.Join(err, transitionErr) + } + } + return err + } + return j.transition(id, "returned-needs-verification") +} + +func workflowV4ContributionOptions(p workflowV4OperationPlan) (roleOpts, position, time.Time, error) { + var o roleOpts + var pos position + var zero time.Time + if p.Kind != "contribute" || len(p.Command) < 3 || len(p.Command)%2 != 1 { + return o, pos, zero, errors.New("invalid saved contribution command") + } + flags := map[string]string{} + for n := 3; n < len(p.Command); n += 2 { + flags[p.Command[n]] = p.Command[n+1] + } + paths := map[string]string{} + for flag, value := range flags { + if !strings.HasPrefix(value, "/") { + continue + } + for destination, source := range p.Runtime.Mounts { + if path, err := pathWithin(destination, value, source); err == nil { + paths[flag] = path + break + } + } + if paths[flag] == "" { + return o, pos, zero, errors.New("contribution path has no saved mount") + } + } + when, err := time.Parse(time.RFC3339, flags["--contributed-at"]) + if err != nil { + return o, pos, zero, err + } + o = roleOpts{root: paths["--transcript-dir"], definition: paths["--ceremony"], definitionSig: paths["--ceremony-signature"], coordinatorKey: paths["--coordinator-public-key-file"], phase: p.Scope.Phase, role: p.Scope.ParticipantID, signingKey: paths["--participant-signing-key"], envPath: paths["--environment"], outDir: paths["--out-dir"], operationID: p.ID, phase1Seal: paths["--phase1-seal"], phase1SealSig: paths["--phase1-seal-signature"], artifactRoot: paths["--artifact-root"], checkpoint: paths["--checkpoint"], checkpointSig: paths["--checkpoint-signature"], attemptID: flags["--attempt-id"]} + pos.chainPath = paths["--chain"] + pos.chain.ChainSignaturePath = paths["--chain-signature"] + pos.nextID, pos.nextIndex = o.role, int(p.Scope.Index) + // Compare the driver's logical argv to the immutable plan before rewriting + // any mount paths or creating a Docker container. + actual := append([]string{"mpc-ceremony"}, contributionCommandArgs(o, pos, when)...) + for n, value := range actual { + if !filepath.IsAbs(value) { + continue + } + actual[n], err = workflowV4ContainerPath(p.Runtime, value) + if err != nil { + return o, pos, zero, err + } + } + if !reflect.DeepEqual(actual, p.Command) { + return o, pos, zero, errors.New("driver contribution differs from the saved command") + } + return o, pos, when, nil +} diff --git a/cmd/relay/workflow_v4_coordinator_guide.go b/cmd/relay/workflow_v4_coordinator_guide.go new file mode 100644 index 0000000..12204b8 --- /dev/null +++ b/cmd/relay/workflow_v4_coordinator_guide.go @@ -0,0 +1,545 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +type workflowV4CoordinatorProgress struct { + Local storagefirst.LocalTurnV4 + GrantPath string + CandidateDir string + // CandidateInspectionError is set only after the coordinator has a + // transport-checked five-file candidate directory for the active attempt. + // It deliberately does not make rejection automatic: the coordinator must + // explicitly decide whether to publish a signed rejection checkpoint. + CandidateInspectionError string + CandidateTransportError string + EnrollmentExpected *transcript.ExpectedEnrollment + EnrollmentGrant *access.StorageFirstGrant + EnrollmentGrantPath string + EnrollmentDir string +} + +type workflowV4CoordinatorIntent struct { + Schema string `json:"schema"` + Action string `json:"action"` + Scope transcript.ContributionScopeV4 `json:"scope"` + Predecessor transcript.SignedArtifactRefs `json:"predecessor"` + AttemptID string `json:"attempt_id"` + At string `json:"at"` + OutputDir string `json:"output_dir"` +} + +const workflowV4CoordinatorIntentSchema = "relay-workflow-v4-coordinator-intent-v1" + +func workflowV4CoordinatorEnrollmentProgressFor(snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, expected transcript.ExpectedEnrollment, binding workflowV4Binding, config access.StorageConfig, now time.Time) (workflowV4CoordinatorProgress, error) { + progress := workflowV4CoordinatorProgress{EnrollmentExpected: &expected} + base := filepath.Join(binding.Work, "workflow-v4", "coordinator", "enrollments", expected.Identity.ID) + grantDir := filepath.Join(base, "grants") + entries, err := os.ReadDir(grantDir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return progress, err + } + destination := storagefirst.GrantDestination{Provider: config.Provider, Endpoint: config.Endpoint, Region: config.Region, InboxBucket: config.InboxBucket} + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + path := filepath.Join(grantDir, entry.Name()) + grant, err := loadStorageFirstGrant(path) + if err != nil { + return progress, fmt.Errorf("read retained enrollment grant %s: %w", path, err) + } + if err := storagefirst.ValidateEnrollmentGrantV4At(snapshot, protocol, expected.Identity.ID, expected.Role, expected.RoleIndex, grant, destination, now); err != nil { + continue + } + expires, _ := time.Parse(time.RFC3339, grant.ExpiresAt) + if progress.EnrollmentGrant == nil { + copy := grant + progress.EnrollmentGrant, progress.EnrollmentGrantPath = ©, path + } else { + current, _ := time.Parse(time.RFC3339, progress.EnrollmentGrant.ExpiresAt) + if expires.After(current) { + copy := grant + progress.EnrollmentGrant, progress.EnrollmentGrantPath = ©, path + } + } + } + if progress.EnrollmentGrant != nil { + progress.EnrollmentDir = filepath.Join(base, "received", progress.EnrollmentGrant.AttemptID) + } + return progress, nil +} + +func workflowV4CoordinatorProgressFor(snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, view storagefirst.TurnViewV4, binding workflowV4Binding, config access.StorageConfig, inspector transcript.Inspector, now time.Time) (workflowV4CoordinatorProgress, error) { + progress := workflowV4CoordinatorProgress{Local: storagefirst.LocalTurnV4{Scope: view.Scope}} + if view.Stage == storagefirst.TurnEnrollmentV4 { + expected, err := workflowV4ExpectedEnrollment(protocol, view.Scope.ParticipantID) + if err != nil { + return progress, err + } + enrollment, err := workflowV4CoordinatorEnrollmentProgressFor(snapshot, protocol, expected, binding, config, now) + enrollment.Local = progress.Local + return enrollment, err + } + if view.CandidateAttempt == nil { + return progress, nil + } + attempt := view.CandidateAttempt.AttemptID + base := workflowV4CoordinatorTurnDir(binding.Work, view.Scope) + grantDir := filepath.Join(base, "grants") + progress.GrantPath = filepath.Join(grantDir, attempt+".json") + entries, err := os.ReadDir(grantDir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return progress, err + } + destination := storagefirst.GrantDestination{Provider: config.Provider, Endpoint: config.Endpoint, Region: config.Region, InboxBucket: config.InboxBucket} + maxRenewal := 0 + for _, entry := range entries { + if entry.IsDir() || (entry.Name() != attempt+".json" && !strings.HasPrefix(entry.Name(), attempt+"-renewal-")) { + continue + } + path := filepath.Join(grantDir, entry.Name()) + grant, err := loadStorageFirstGrant(path) + if err != nil { + return progress, fmt.Errorf("read retained grant %s: %w", path, err) + } + if err := storagefirst.ValidateGrantV4At(snapshot, protocol, view.Scope.ParticipantID, grant, destination, now); err == nil { + expires, _ := time.Parse(time.RFC3339, grant.ExpiresAt) + if progress.Local.Grant == nil || expires.After(progress.Local.Grant.ExpiresAt) { + progress.Local.Grant = &storagefirst.TurnGrantV4{AttemptID: grant.AttemptID, ExpiresAt: expires} + progress.GrantPath = path + } + } + if suffix, ok := strings.CutPrefix(strings.TrimSuffix(entry.Name(), ".json"), attempt+"-renewal-"); ok { + if number, err := strconv.Atoi(suffix); err == nil && number > maxRenewal { + maxRenewal = number + } + } + } + if progress.Local.Grant == nil { + if _, err := os.Lstat(progress.GrantPath); err == nil { + progress.GrantPath = filepath.Join(grantDir, fmt.Sprintf("%s-renewal-%02d.json", attempt, maxRenewal+1)) + } else if !errors.Is(err, os.ErrNotExist) { + return progress, err + } + } + progress.CandidateDir = filepath.Join(base, "candidates", attempt) + if info, err := os.Lstat(progress.CandidateDir); err == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + progress.CandidateTransportError = "retained candidate path is not a regular directory" + return progress, nil + } + if err := validateWorkflowV4CandidateFetchReceipt(progress.CandidateDir, view.Scope, attempt); err != nil { + progress.CandidateTransportError = err.Error() + return progress, nil + } + scopePath := filepath.Join(base, "scope.json") + if err := writeWorkflowV4Scope(scopePath, view.Scope); err != nil { + return progress, err + } + state, err := snapshot.State() + if err != nil { + return progress, err + } + phaseState := state.Progress.Phase1 + if view.Scope.Phase == "phase2" { + if state.Progress.Phase2 == nil { + return progress, errors.New("phase2 candidate has no authenticated phase state") + } + phaseState = *state.Progress.Phase2 + } + inventory, inspectionErr, err := workflowV4CoordinatorInspectCandidate(inspector, phaseState, scopePath, progress.CandidateDir, view.Scope) + if err != nil { + return progress, err + } + if inspectionErr != "" { + // fetch-candidate-v4 creates this directory only after the delivery + // manifest and every fixed candidate byte have been checked. A failed + // proof-tool inspection is therefore a review decision, not a reason + // to discard the immutable downloaded package or to hide the recovery + // path behind a generic error. + progress.CandidateInspectionError = inspectionErr + return progress, nil + } + progress.Local.CandidateInventory = inventory + progress.Local.ComputedCandidateID = inventory.ComputedCandidateID + progress.Local.CandidateResultID = inventory.CandidateResultID + progress.Local.CandidateReceivedAttemptID = attempt + } + return progress, nil +} + +// workflowV4CoordinatorInspectCandidate is deliberately narrow: only an +// approved proof-tool candidate-inspection failure becomes a reviewable +// rejected-candidate path. Errors resolving the authenticated state stay +// fatal in workflowV4CoordinatorProgressFor. +func workflowV4CoordinatorInspectCandidate(inspector transcript.Inspector, phaseState transcript.CheckpointPhaseState, scopePath, candidateDir string, scope transcript.ContributionScopeV4) (*transcript.ContributionInventoryFactsV4, string, error) { + chain := filepath.Join(inspector.TranscriptRoot, filepath.FromSlash(phaseState.Chain.Record.Name)) + signature := filepath.Join(inspector.TranscriptRoot, filepath.FromSlash(phaseState.Chain.Signature.Name)) + inventory, err := inspector.ContributionInventoryV4(chain, signature, scopePath, candidateDir, scope, phaseState.Chain) + if err != nil { + if errors.Is(err, transcript.ErrCandidateInvalidV4) { + return nil, err.Error(), nil + } + return nil, "", err + } + return &inventory, "", nil +} + +func workflowV4CoordinatorActionLabel(recommendation storagefirst.TurnRecommendationV4, progress workflowV4CoordinatorProgress) string { + switch recommendation.Action { + case "collect-participant-enrollment": + if progress.EnrollmentGrant == nil { + return "Create the participant's enrollment upload grant" + } + if !regularPreparationFile(filepath.Join(progress.EnrollmentDir, "enrollment.json")) { + return "Check the private inbox for the participant's enrollment" + } + return "Verify and record the participant's enrollment" + case "allocate-candidate-attempt", "allocate-replacement-attempt": + return "Allocate the next candidate upload attempt" + case "issue-candidate-grant": + return "Create the participant's private upload grant" + case "wait-for-candidate": + return "Check the private inbox for this candidate" + case "download-and-check-candidate": + return "Download and verify the uploaded five-file candidate" + case "recover-candidate-download": + return "Preserve the unverifiable download and fetch this attempt again" + case "verify-and-accept-candidate": + return "Replay, verify and accept the exact candidate" + case "review-and-reject-candidate": + return "Review the received candidate and reject it if appropriate" + } + return "" +} + +func runWorkflowV4CoordinatorAction(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, config access.StorageConfig, online, signer guidedProfile, inspector transcript.Inspector, recommendation storagefirst.TurnRecommendationV4, view storagefirst.TurnViewV4, progress workflowV4CoordinatorProgress) error { + switch recommendation.Action { + case "collect-participant-enrollment": + return runWorkflowV4CoordinatorEnrollment(ui, snapshot, protocol, config, online, signer, inspector, view, progress) + case "allocate-candidate-attempt", "allocate-replacement-attempt": + return runWorkflowV4CoordinatorAllocation(ui, snapshot, online, signer, view) + case "issue-candidate-grant": + return runWorkflowV4CoordinatorGrant(ui, config, online, view, progress) + case "wait-for-candidate", "download-and-check-candidate", "recover-candidate-download": + return runWorkflowV4CoordinatorFetch(ui, config, online, snapshot, view, progress) + case "verify-and-accept-candidate": + return runWorkflowV4CoordinatorAcceptance(ui, snapshot, online, signer, view, progress) + case "review-and-reject-candidate": + return runWorkflowV4CoordinatorRejection(ui, snapshot, online, signer, view, progress) + default: + return fmt.Errorf("current signed state does not authorize a coordinator action: %s", recommendation.Reason) + } +} + +func runWorkflowV4CoordinatorAllocation(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, online, signer guidedProfile, view storagefirst.TurnViewV4) error { + basis := strings.TrimPrefix(snapshot.Head().Record.Digest.SHA256, "sha256:")[:16] + intentPath := filepath.Join(workflowV4CoordinatorTurnDir(online.Work, view.Scope), "allocation-"+basis+"-intent.json") + outputDir := workflowV4CoordinatorCheckpointDir(online.Work, view.Scope, "allocate", basis) + intent, err := loadOrCreateWorkflowV4CoordinatorIntent(intentPath, "allocate", snapshot.Head(), view.Scope, "", outputDir) + if err != nil { + return err + } + if err := ui.confirm("Sign and publish one allocation for this exact participant and current transcript head", "ALLOCATE TURN"); err != nil { + return err + } + if _, err := os.Lstat(filepath.Join(intent.OutputDir, "checkpoint.json")); errors.Is(err, os.ErrNotExist) { + command, err := workflowV4CheckpointCommand(signer, online, snapshot.Head(), intent, "allocate-v4", "") + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } else if err != nil { + return err + } + return runWorkflowV4CommitCommand(online, intent.OutputDir) +} + +func runWorkflowV4CoordinatorGrant(ui *coordinatorWizard, config access.StorageConfig, online guidedProfile, view storagefirst.TurnViewV4, progress workflowV4CoordinatorProgress) error { + if view.CandidateAttempt == nil || view.Commitment == nil { + return errors.New("active authenticated allocation required") + } + var checkpointDigest string + for _, allocation := range view.Commitment.Allocations { + if allocation.AttemptID == view.CandidateAttempt.AttemptID { + checkpointDigest = allocation.Checkpoint.Record.Digest.SHA256 + } + } + if checkpointDigest == "" { + return errors.New("active attempt has no exact allocation checkpoint") + } + if err := os.MkdirAll(filepath.Dir(progress.GrantPath), 0o700); err != nil { + return err + } + if err := ui.confirm("Create private upload access for only this participant and attempt", "CREATE GRANT"); err != nil { + return err + } + storage, _ := pathWithin(online.Work, filepath.Join(online.Work, "ceremony", "config", "relay-storage.json"), "/work") + out, _ := pathWithin(online.Work, progress.GrantPath, "/work") + command := []string{"relay", "coordinator", "grant", "--storage", storage, "--role", access.RoleParticipant, "--identity", view.Scope.ParticipantID, "--credential-ttl", "1h", "--minimum-remaining", "15m", "--out", out, "--checkpoint-digest", checkpointDigest, "--submission-kind", access.SubmissionKindCandidate, "--phase", view.Scope.Phase, "--index", strconv.Itoa(int(view.Scope.Index)), "--attempt-id", view.CandidateAttempt.AttemptID} + if err := runWorkflowV4ProfileCommand(online, command, true); err != nil { + return err + } + fmt.Fprintf(ui.output, "Give this private grant only to %s: %s\nIt is upload access, not a signing key.\n", view.Scope.ParticipantID, progress.GrantPath) + return nil +} + +func runWorkflowV4CoordinatorFetch(ui *coordinatorWizard, _ access.StorageConfig, online guidedProfile, snapshot storagefirst.SnapshotV4, view storagefirst.TurnViewV4, progress workflowV4CoordinatorProgress) error { + if view.CandidateAttempt == nil { + return errors.New("active candidate attempt required") + } + recovering := false + if _, err := os.Lstat(progress.CandidateDir); err == nil { + if progress.CandidateTransportError == "" { + return nil + } + recovering = true + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + confirmation := "CHECK INBOX" + prompt := "Check the private inbox and download this exact attempt if its manifest is present" + if recovering { + confirmation = "PRESERVE AND REFRESH" + prompt = "Preserve the unverifiable local candidate and its receipt, then fetch this exact attempt again from the private inbox" + } + if err := ui.confirm(prompt, confirmation); err != nil { + return err + } + if recovering { + retained, err := quarantineWorkflowV4CandidateDownload(progress.CandidateDir) + if err != nil { + return err + } + fmt.Fprintf(ui.output, "Preserved the unverifiable download at %s. Fetching the same authenticated attempt into a fresh folder.\n", retained) + } + command, err := workflowV4OnlineBaseCommand(online, snapshot, "fetch-candidate-v4") + if err != nil { + return err + } + storage, _ := pathWithin(online.Work, filepath.Join(online.Work, "ceremony", "config", "relay-storage.json"), "/work") + out, _ := pathWithin(online.Work, progress.CandidateDir, "/work") + command = append(command, "--storage", storage, "--attempt-id", view.CandidateAttempt.AttemptID, "--out-dir", out) + return runWorkflowV4ProfileCommand(online, command, true) +} + +func runWorkflowV4CoordinatorAcceptance(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, online, signer guidedProfile, view storagefirst.TurnViewV4, progress workflowV4CoordinatorProgress) error { + if view.CandidateAttempt == nil || progress.Local.CandidateInventory == nil { + return errors.New("transport-checked and proof-tool-verified candidate required") + } + intentPath := filepath.Join(workflowV4CoordinatorTurnDir(online.Work, view.Scope), "acceptance-"+view.CandidateAttempt.AttemptID+"-intent.json") + outputDir := workflowV4CoordinatorCheckpointDir(online.Work, view.Scope, "accept", view.CandidateAttempt.AttemptID) + intent, err := loadOrCreateWorkflowV4CoordinatorIntent(intentPath, "accept", snapshot.Head(), view.Scope, view.CandidateAttempt.AttemptID, outputDir) + if err != nil { + return err + } + if err := ui.confirm("Replay the contribution mathematics, verify the exact five files and publish acceptance", "VERIFY AND ACCEPT"); err != nil { + return err + } + if err := validateWorkflowV4CandidateFetchReceipt(progress.CandidateDir, view.Scope, view.CandidateAttempt.AttemptID); err != nil { + return fmt.Errorf("candidate changed after transport verification: %w", err) + } + if _, err := os.Lstat(filepath.Join(intent.OutputDir, "checkpoint.json")); errors.Is(err, os.ErrNotExist) { + command, err := workflowV4CheckpointCommand(signer, online, snapshot.Head(), intent, "accept-candidate-v4", progress.CandidateDir) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } else if err != nil { + return err + } + return runWorkflowV4CommitCommand(online, intent.OutputDir) +} + +func runWorkflowV4CoordinatorRejection(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, online, signer guidedProfile, view storagefirst.TurnViewV4, progress workflowV4CoordinatorProgress) error { + if view.CandidateAttempt == nil || progress.CandidateDir == "" || progress.CandidateTransportError != "" || progress.CandidateInspectionError == "" { + return errors.New("a transport-checked candidate that failed proof-tool inspection is required") + } + intentPath := filepath.Join(workflowV4CoordinatorTurnDir(online.Work, view.Scope), "rejection-"+view.CandidateAttempt.AttemptID+"-intent.json") + outputDir := workflowV4CoordinatorCheckpointDir(online.Work, view.Scope, "reject", view.CandidateAttempt.AttemptID) + intent, err := loadOrCreateWorkflowV4CoordinatorIntent(intentPath, "reject", snapshot.Head(), view.Scope, view.CandidateAttempt.AttemptID, outputDir) + if err != nil { + return err + } + if err := ui.confirm("Reject this exact transport-checked candidate. Its five private files stay retained for investigation; a later allocation requires a fresh contribution.", "REJECT CANDIDATE"); err != nil { + return err + } + if err := validateWorkflowV4CandidateFetchReceipt(progress.CandidateDir, view.Scope, view.CandidateAttempt.AttemptID); err != nil { + return fmt.Errorf("candidate changed after transport verification: %w", err) + } + if _, err := os.Lstat(filepath.Join(intent.OutputDir, "checkpoint.json")); errors.Is(err, os.ErrNotExist) { + command, err := workflowV4CheckpointCommand(signer, online, snapshot.Head(), intent, "reject-candidate-v4", progress.CandidateDir) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } else if err != nil { + return err + } + return runWorkflowV4CommitCommand(online, intent.OutputDir) +} + +func workflowV4CoordinatorTurnDir(work string, scope transcript.ContributionScopeV4) string { + return filepath.Join(work, "workflow-v4", "coordinator", scope.Phase+"-"+fmt.Sprintf("%02d", scope.Index)+"-"+scope.ParticipantID) +} + +func workflowV4CoordinatorCheckpointDir(work string, scope transcript.ContributionScopeV4, action, attempt string) string { + name := action + if attempt != "" { + name += "-" + attempt + } + return filepath.Join(work, "ceremony", "public", "checkpoints", scope.Phase, fmt.Sprintf("%02d", scope.Index), name) +} + +func loadOrCreateWorkflowV4CoordinatorIntent(path, action string, predecessor transcript.SignedArtifactRefs, scope transcript.ContributionScopeV4, attempt, outputDir string) (workflowV4CoordinatorIntent, error) { + var intent workflowV4CoordinatorIntent + if err := readWorkflowV4JSON(path, &intent); err == nil { + if intent.Schema != workflowV4CoordinatorIntentSchema || intent.Action != action || intent.Scope != scope || intent.Predecessor != predecessor || intent.OutputDir != outputDir || (attempt != "" && intent.AttemptID != attempt) { + return intent, errors.New("retained coordinator intent belongs to different authenticated state") + } + return intent, nil + } else if !errors.Is(err, os.ErrNotExist) { + return intent, err + } + if attempt == "" { + var err error + attempt, err = randomID() + if err != nil { + return intent, err + } + } + stamp := time.Now().UTC().Truncate(time.Second).Format(time.RFC3339) + intent = workflowV4CoordinatorIntent{Schema: workflowV4CoordinatorIntentSchema, Action: action, Scope: scope, Predecessor: predecessor, AttemptID: attempt, At: stamp, OutputDir: outputDir} + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return intent, err + } + if err := os.MkdirAll(filepath.Dir(outputDir), 0o700); err != nil { + return intent, err + } + if err := writeJSONNoReplace(path, intent, 0o600); err != nil { + return intent, err + } + return intent, nil +} + +func workflowV4CheckpointCommand(signer, online guidedProfile, head transcript.SignedArtifactRefs, intent workflowV4CoordinatorIntent, action, candidate string) ([]string, error) { + root := filepath.Join(online.Work, "ceremony", "public") + paths := []string{filepath.Join(root, filepath.FromSlash(head.Record.Name)), filepath.Join(root, filepath.FromSlash(head.Signature.Name)), intent.OutputDir} + if candidate != "" { + paths = append(paths, candidate) + } + container := make([]string, len(paths)) + for n, path := range paths { + mapped, err := pathWithin(signer.Work, path, "/work") + if err != nil { + return nil, err + } + container[n] = mapped + } + ceremony, _ := pathWithin(signer.Work, filepath.Join(root, "ceremony.json"), "/work") + ceremonySig, _ := pathWithin(signer.Work, filepath.Join(root, "ceremony.sig"), "/work") + coordinatorKey, _ := pathWithin(signer.Trust, filepath.Join(online.Trust, "setup-coordinator.hex"), "/trust") + artifactRoot, _ := pathWithin(signer.Work, root, "/work") + command := []string{"mpc-ceremony", "checkpoint", action, "--ceremony", ceremony, "--ceremony-signature", ceremonySig, "--coordinator-public-key-file", coordinatorKey, "--artifact-root", artifactRoot, "--checkpoint", container[0], "--checkpoint-signature", container[1], "--attempt-id", intent.AttemptID, "--coordinator-signing-key", "/keys/signing.hex", "--out-dir", container[2]} + switch action { + case "allocate-v4": + command = append(command, "--allocated-at", intent.At) + case "accept-candidate-v4": + if candidate == "" { + return nil, errors.New("candidate directory required for acceptance") + } + command = append(command, "--candidate-dir", container[3], "--accepted-at", intent.At) + case "reject-candidate-v4": + if candidate == "" { + return nil, errors.New("candidate directory required for rejection") + } + command = append(command, "--rejected-candidate-dir", container[3]) + default: + return nil, fmt.Errorf("unsupported V4 checkpoint command %q", action) + } + return command, nil +} + +func workflowV4OnlineBaseCommand(online guidedProfile, snapshot storagefirst.SnapshotV4, action string) ([]string, error) { + root := filepath.Join(online.Work, "ceremony", "public") + head := snapshot.Head() + values := []string{root, filepath.Join(root, filepath.FromSlash(head.Record.Name)), filepath.Join(root, filepath.FromSlash(head.Signature.Name)), filepath.Join(root, "ceremony.json"), filepath.Join(root, "ceremony.sig")} + mapped := make([]string, len(values)) + for n, value := range values { + var err error + mapped[n], err = pathWithin(online.Work, value, "/work") + if err != nil { + return nil, err + } + } + key, err := pathWithin(online.Trust, filepath.Join(online.Trust, "setup-coordinator.hex"), "/trust") + if err != nil { + return nil, err + } + return []string{"relay", "coordinator", action, "--artifact-root", mapped[0], "--checkpoint", mapped[1], "--checkpoint-signature", mapped[2], "--ceremony", mapped[3], "--ceremony-signature", mapped[4], "--coordinator-key", key}, nil +} + +func runWorkflowV4CommitCommand(online guidedProfile, outputDir string) error { + root := filepath.Join(online.Work, "ceremony", "public") + checkpoint := filepath.Join(outputDir, "checkpoint.json") + signature := filepath.Join(outputDir, "checkpoint.sig") + values := []string{root, checkpoint, signature, filepath.Join(root, "ceremony.json"), filepath.Join(root, "ceremony.sig")} + mapped := make([]string, len(values)) + for n, value := range values { + var err error + mapped[n], err = pathWithin(online.Work, value, "/work") + if err != nil { + return err + } + } + key, err := pathWithin(online.Trust, filepath.Join(online.Trust, "setup-coordinator.hex"), "/trust") + if err != nil { + return err + } + command := []string{"relay", "coordinator", "commit-v4", "--artifact-root", mapped[0], "--checkpoint", mapped[1], "--checkpoint-signature", mapped[2], "--ceremony", mapped[3], "--ceremony-signature", mapped[4], "--coordinator-key", key} + storage, _ := pathWithin(online.Work, filepath.Join(online.Work, "ceremony", "config", "relay-storage.json"), "/work") + command = append(command, "--storage", storage) + return runWorkflowV4ProfileCommand(online, command, true) +} + +// workflowV4ChildExecutor is a test seam for live workflow tests. Production +// uses the installed Relay executable so signals and terminal I/O retain the +// same process boundary as every other guided action. +var workflowV4ChildExecutor = executeGuidedChild + +func runWorkflowV4ProfileCommand(profile guidedProfile, command []string, credentials bool) error { + launch := []string{"role", "--role", profile.Role, "--image", profile.Image, "--platform", profile.Platform, "--work", profile.Work} + for _, pair := range [][2]string{{"--trust", profile.Trust}, {"--keys", profile.Keys}} { + if pair[1] != "" { + launch = append(launch, pair[0], pair[1]) + } + } + if credentials { + for _, pair := range [][2]string{{"--aws-credentials", profile.Credentials}, {"--r2-parent-credential", profile.R2Parent}, {"--r2-control-credential", profile.R2Control}} { + if pair[1] != "" { + launch = append(launch, pair[0], pair[1]) + } + } + } + launch = append(launch, "--") + launch = append(launch, command...) + return workflowV4ChildExecutor(launch) +} diff --git a/cmd/relay/workflow_v4_coordinator_guide_test.go b/cmd/relay/workflow_v4_coordinator_guide_test.go new file mode 100644 index 0000000..f541ecf --- /dev/null +++ b/cmd/relay/workflow_v4_coordinator_guide_test.go @@ -0,0 +1,277 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +func TestWorkflowV4CoordinatorIntentIsStableAndCheckpointStaysPublic(t *testing.T) { + work := t.TempDir() + scope := transcript.ContributionScopeV4{CeremonyID: "sha256:" + strings.Repeat("1", 64), Phase: "phase1", Index: 1, ParticipantID: "participant", ParentHeadID: "sha256:" + strings.Repeat("2", 64)} + pair := pairV4Test("head") + path := filepath.Join(workflowV4CoordinatorTurnDir(work, scope), "allocation-intent.json") + output := workflowV4CoordinatorCheckpointDir(work, scope, "allocate", "basis") + first, err := loadOrCreateWorkflowV4CoordinatorIntent(path, "allocate", pair, scope, "", output) + if err != nil { + t.Fatal(err) + } + second, err := loadOrCreateWorkflowV4CoordinatorIntent(path, "allocate", pair, scope, "", output) + if err != nil || second != first { + t.Fatalf("unstable intent: first=%+v second=%+v err=%v", first, second, err) + } + public := filepath.Join(work, "ceremony", "public") + if relative, err := filepath.Rel(public, first.OutputDir); err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + t.Fatalf("checkpoint output escaped public artifact root: %s", first.OutputDir) + } + changed := pair + changed.Record.Digest.SHA256 = "sha256:" + strings.Repeat("3", 64) + if _, err := loadOrCreateWorkflowV4CoordinatorIntent(path, "allocate", changed, scope, "", output); err == nil { + t.Fatal("retained intent was silently rebound to another predecessor") + } +} + +func TestWorkflowV4CoordinatorCheckpointCommandsUseMountedPaths(t *testing.T) { + work, trust, keys := t.TempDir(), t.TempDir(), t.TempDir() + for _, dir := range []string{filepath.Join(work, "ceremony", "public"), filepath.Join(trust), filepath.Join(keys)} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + online := guidedProfile{Role: "coordinator", Work: work, Trust: trust, Keys: keys} + signer := guidedProfile{Role: "decision-signer", Work: work, Trust: trust, Keys: keys} + scope := transcript.ContributionScopeV4{CeremonyID: "sha256:" + strings.Repeat("1", 64), Phase: "phase1", Index: 1, ParticipantID: "p", ParentHeadID: "sha256:" + strings.Repeat("2", 64)} + intent := workflowV4CoordinatorIntent{Action: "allocate", Scope: scope, AttemptID: strings.Repeat("a", 32), At: "2026-09-16T00:00:00Z", OutputDir: workflowV4CoordinatorCheckpointDir(work, scope, "allocate", "basis")} + head := pairV4Test("head") + command, err := workflowV4CheckpointCommand(signer, online, head, intent, "allocate-v4", "") + if err != nil { + t.Fatal(err) + } + joined := strings.Join(command, " ") + for _, want := range []string{"checkpoint allocate-v4", "--artifact-root /work/ceremony/public", "--coordinator-signing-key /keys/signing.hex", "--attempt-id " + intent.AttemptID} { + if !strings.Contains(joined, want) { + t.Fatalf("command %q lacks %q", joined, want) + } + } +} + +func TestWorkflowV4CoordinatorRejectCommandUsesExactPrivateCandidate(t *testing.T) { + work, trust, keys := t.TempDir(), t.TempDir(), t.TempDir() + for _, dir := range []string{filepath.Join(work, "ceremony", "public"), filepath.Join(work, "workflow-v4", "candidate"), trust, keys} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + online := guidedProfile{Role: "coordinator", Work: work, Trust: trust, Keys: keys} + signer := guidedProfile{Role: "decision-signer", Work: work, Trust: trust, Keys: keys} + scope := transcript.ContributionScopeV4{CeremonyID: "sha256:" + strings.Repeat("1", 64), Phase: "phase1", Index: 1, ParticipantID: "p", ParentHeadID: "sha256:" + strings.Repeat("2", 64)} + head := pairV4Test("head") + candidate := filepath.Join(work, "workflow-v4", "candidate") + intent := workflowV4CoordinatorIntent{Action: "reject", Scope: scope, AttemptID: strings.Repeat("a", 32), OutputDir: workflowV4CoordinatorCheckpointDir(work, scope, "reject", strings.Repeat("a", 32))} + command, err := workflowV4CheckpointCommand(signer, online, head, intent, "reject-candidate-v4", candidate) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(command, " ") + for _, want := range []string{"checkpoint reject-candidate-v4", "--rejected-candidate-dir /work/workflow-v4/candidate", "--attempt-id " + intent.AttemptID} { + if !strings.Contains(joined, want) { + t.Fatalf("command %q lacks %q", joined, want) + } + } + if strings.Contains(joined, "--accepted-at") || strings.Contains(joined, "--candidate-dir") { + t.Fatalf("rejection command incorrectly looks like acceptance: %q", joined) + } + if _, err := workflowV4CheckpointCommand(signer, online, head, intent, "reject-candidate-v4", ""); err == nil { + t.Fatal("rejection accepted an empty candidate directory") + } +} + +func TestWorkflowV4CoordinatorRejectLabelIsExplicit(t *testing.T) { + got := workflowV4CoordinatorActionLabel(storagefirst.TurnRecommendationV4{Action: "review-and-reject-candidate"}, workflowV4CoordinatorProgress{CandidateInspectionError: "bad signature"}) + if got != "Review the received candidate and reject it if appropriate" { + t.Fatalf("label = %q", got) + } +} + +func TestWorkflowV4CoordinatorTransportRecoveryIsActionable(t *testing.T) { + scope := transcript.ContributionScopeV4{CeremonyID: "sha256:" + strings.Repeat("1", 64), Phase: "phase1", Index: 1, ParticipantID: "p", ParentHeadID: "sha256:" + strings.Repeat("2", 64)} + turn := storagefirst.TurnViewV4{Scope: scope, CandidateAttempt: &transcript.DeliverySlotV4{AttemptID: strings.Repeat("a", 32)}} + recommendation, ok := workflowV4CoordinatorTransportRecoveryRecommendation(turn, workflowV4CoordinatorProgress{CandidateDir: "/private/candidate", CandidateTransportError: "missing receipt"}) + if !ok || recommendation.Action != "recover-candidate-download" || !recommendation.Ready || recommendation.Scope != scope || recommendation.AttemptID != turn.CandidateAttempt.AttemptID { + t.Fatalf("recovery recommendation = %+v, ok=%v", recommendation, ok) + } + if got := workflowV4CoordinatorActionLabel(recommendation, workflowV4CoordinatorProgress{}); got != "Preserve the unverifiable download and fetch this attempt again" { + t.Fatalf("recovery label = %q", got) + } + if _, ok := workflowV4CoordinatorTransportRecoveryRecommendation(turn, workflowV4CoordinatorProgress{CandidateDir: "/private/candidate"}); ok { + t.Fatal("candidate without transport failure became a recovery action") + } +} + +func TestWorkflowV4CoordinatorInspectionFailureIsReviewable(t *testing.T) { + root := t.TempDir() + candidate := filepath.Join(root, "received-candidate") + if err := os.Mkdir(candidate, 0o700); err != nil { + t.Fatal(err) + } + scope := transcript.ContributionScopeV4{CeremonyID: "sha256:" + strings.Repeat("1", 64), Phase: "phase1", Index: 1, ParticipantID: "p", ParentHeadID: "sha256:" + strings.Repeat("2", 64)} + phase := transcript.CheckpointPhaseState{Chain: pairV4Test("head")} + inspector := transcript.Inspector{ + CeremonyPath: filepath.Join(root, "ceremony.json"), + CeremonySignaturePath: filepath.Join(root, "ceremony.sig"), + CoordinatorPublicKeyPath: filepath.Join(root, "coordinator.hex"), + TranscriptRoot: root, + Runner: func(_ string, _ ...string) ([]byte, []byte, error) { + return []byte(`{"schema":"proof-tool-mpc-command-result-v1","ok":false,"command":"inspect contribution-inventory-v4","error":{"code":"candidate_invalid","message":"candidate signature is invalid"}}`), nil, errors.New("exit status 6") + }, + } + inventory, inspectionErr, err := workflowV4CoordinatorInspectCandidate(inspector, phase, filepath.Join(root, "scope.json"), candidate, scope) + if err != nil || inventory != nil || inspectionErr == "" { + t.Fatalf("inventory=%+v inspectionErr=%q err=%v", inventory, inspectionErr, err) + } + turn := storagefirst.TurnViewV4{Scope: scope, CandidateAttempt: &transcript.DeliverySlotV4{AttemptID: strings.Repeat("a", 32)}} + recommendation, ok := workflowV4CoordinatorCandidateRecommendation(turn, workflowV4CoordinatorProgress{CandidateDir: candidate, CandidateInspectionError: inspectionErr}) + if !ok || recommendation.Action != "review-and-reject-candidate" || !recommendation.Ready || recommendation.Scope != scope || recommendation.AttemptID != turn.CandidateAttempt.AttemptID { + t.Fatalf("recommendation = %+v, ok=%v", recommendation, ok) + } + if _, ok := workflowV4CoordinatorCandidateRecommendation(turn, workflowV4CoordinatorProgress{CandidateDir: candidate}); ok { + t.Fatal("candidate without an inspection error became rejectable") + } + inspector.Runner = func(_ string, _ ...string) ([]byte, []byte, error) { + return []byte(`{"schema":"proof-tool-mpc-command-result-v1","ok":false,"command":"inspect contribution-inventory-v4","error":{"code":"internal_error","message":"candidate path is unavailable"}}`), nil, errors.New("exit status 6") + } + if _, candidateErr, err := workflowV4CoordinatorInspectCandidate(inspector, phase, filepath.Join(root, "scope.json"), candidate, scope); err == nil || candidateErr != "" { + t.Fatalf("operational inspection error was made rejectable: candidateErr=%q err=%v", candidateErr, err) + } +} + +func TestWorkflowV4CandidateFetchReceiptRequiresExactRetainedFiles(t *testing.T) { + root := t.TempDir() + candidate := filepath.Join(root, "candidate") + if err := os.Mkdir(candidate, 0o700); err != nil { + t.Fatal(err) + } + scope := transcript.ContributionScopeV4{CeremonyID: "sha256:" + strings.Repeat("1", 64), Phase: "phase1", Index: 1, ParticipantID: "p", ParentHeadID: "sha256:" + strings.Repeat("2", 64)} + attempt := strings.Repeat("a", 32) + files := map[string][]byte{ + "attestation.json": []byte("attestation"), + "attestation.sig": []byte("attestation signature"), + "contribution.bin": []byte("contribution"), + "erasure.json": []byte("erasure"), + "erasure.sig": []byte("erasure signature"), + } + receipt := workflowV4CandidateFetchReceipt{Schema: workflowV4CandidateFetchReceiptSchema, CeremonyID: scope.CeremonyID, AttemptID: attempt, Kind: "candidate", Manifest: state.ContentRef{Name: "manifest.json", SHA256: "sha256:" + strings.Repeat("b", 64), Size: 1}} + for name, raw := range files { + path := filepath.Join(candidate, name) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + sha, size, err := transcript.DigestFile(path) + if err != nil { + t.Fatal(err) + } + receipt.Files = append(receipt.Files, state.ContentRef{Name: name, SHA256: sha, Size: size}) + } + if err := writeJSONNoReplace(workflowV4CandidateFetchReceiptPath(candidate), receipt, 0o600); err != nil { + t.Fatal(err) + } + if err := validateWorkflowV4CandidateFetchReceipt(candidate, scope, attempt); err != nil { + t.Fatalf("valid fetched package rejected: %v", err) + } + if err := os.WriteFile(filepath.Join(candidate, "contribution.bin"), []byte("changed"), 0o600); err != nil { + t.Fatal(err) + } + if err := validateWorkflowV4CandidateFetchReceipt(candidate, scope, attempt); err == nil { + t.Fatal("changed retained candidate became rejectable") + } + if err := os.Remove(workflowV4CandidateFetchReceiptPath(candidate)); err != nil { + t.Fatal(err) + } + if err := validateWorkflowV4CandidateFetchReceipt(candidate, scope, attempt); err == nil { + t.Fatal("candidate without a transport receipt became rejectable") + } +} + +func TestQuarantineWorkflowV4CandidateDownloadPreservesIncompleteAttempt(t *testing.T) { + root := t.TempDir() + candidate := filepath.Join(root, "candidates", "attempt") + if err := os.MkdirAll(candidate, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(candidate, "contribution.bin"), []byte("incomplete"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(workflowV4CandidateFetchReceiptPath(candidate), []byte(`{"schema":"incomplete"}`), 0o600); err != nil { + t.Fatal(err) + } + retained, err := quarantineWorkflowV4CandidateDownload(candidate) + if err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(candidate); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("candidate remains in the fresh output location: %v", err) + } + if _, err := os.Stat(filepath.Join(retained, "candidate", "contribution.bin")); err != nil { + t.Fatalf("candidate was not preserved: %v", err) + } + if _, err := os.Stat(filepath.Join(retained, "transport-receipt.json")); err != nil { + t.Fatalf("receipt was not preserved: %v", err) + } +} + +func TestQuarantineWorkflowV4CandidateDownloadRecoversRenameBeforeReceipt(t *testing.T) { + root := t.TempDir() + candidate := filepath.Join(root, "candidates", "attempt") + if err := os.MkdirAll(candidate, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(candidate, "contribution.bin"), []byte("downloaded before crash"), 0o600); err != nil { + t.Fatal(err) + } + retained, err := quarantineWorkflowV4CandidateDownload(candidate) + if err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(workflowV4CandidateFetchReceiptPath(candidate)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unexpected receipt after rename-before-receipt recovery: %v", err) + } + if _, err := os.Stat(filepath.Join(retained, "candidate", "contribution.bin")); err != nil { + t.Fatalf("candidate from rename-before-receipt crash was not preserved: %v", err) + } +} + +func TestQuarantineWorkflowV4CandidateDownloadPreservesUnexpectedPathType(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "other") + if err := os.WriteFile(target, []byte("not a candidate directory"), 0o600); err != nil { + t.Fatal(err) + } + candidate := filepath.Join(root, "candidates", "attempt") + if err := os.MkdirAll(filepath.Dir(candidate), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, candidate); err != nil { + t.Fatal(err) + } + retained, err := quarantineWorkflowV4CandidateDownload(candidate) + if err != nil { + t.Fatal(err) + } + info, err := os.Lstat(filepath.Join(retained, "candidate")) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("unexpected retained path after corrupt local candidate: info=%v err=%v", info, err) + } +} + +func pairV4Test(seed string) transcript.SignedArtifactRefs { + digest := func(s string) transcript.Digest { + return transcript.Digest{SHA256: "sha256:" + strings.Repeat(s, 64), Blake2b256: "blake2b256:" + strings.Repeat(s, 64), Size: 10} + } + return transcript.SignedArtifactRefs{Record: transcript.ArtifactRef{Name: "checkpoints/" + seed + "/checkpoint.json", Digest: digest("a")}, Signature: transcript.ArtifactRef{Name: "checkpoints/" + seed + "/checkpoint.sig", Digest: digest("b")}} +} diff --git a/cmd/relay/workflow_v4_enrollment.go b/cmd/relay/workflow_v4_enrollment.go new file mode 100644 index 0000000..4a30d98 --- /dev/null +++ b/cmd/relay/workflow_v4_enrollment.go @@ -0,0 +1,436 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +func workflowV4ExpectedEnrollment(protocol transcript.DefinitionProtocol, identity string) (transcript.ExpectedEnrollment, error) { + journey, err := protocol.Definition.RequireJourney() + if err != nil { + return transcript.ExpectedEnrollment{}, err + } + var result transcript.ExpectedEnrollment + for _, expected := range journey.RequiredEnrollments { + if expected.Identity.ID == identity { + if result.Identity.ID != "" { + return result, errors.New("identity has multiple signed enrollment assignments") + } + result = expected + } + } + if result.Identity.ID == "" { + return result, errors.New("identity has no signed enrollment assignment") + } + return result, nil +} + +func workflowV4NextRequiredEnrollment(snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol) (*transcript.ExpectedEnrollment, error) { + metadata, err := snapshot.Enrollments() + if err != nil { + return nil, err + } + committed := make(map[string]bool, len(metadata.Enrollments)) + for _, item := range metadata.Enrollments { + committed[item.Enrollment.Identity.ID] = true + } + journey, err := protocol.Definition.RequireJourney() + if err != nil { + return nil, err + } + for _, expected := range journey.RequiredEnrollments { + if !committed[expected.Identity.ID] { + copy := expected + return ©, nil + } + } + return nil, nil +} + +func workflowV4EnrollmentCommitted(snapshot storagefirst.SnapshotV4, identity string) (bool, error) { + metadata, err := snapshot.Enrollments() + if err != nil { + return false, err + } + for _, item := range metadata.Enrollments { + if item.Enrollment.Identity.ID == identity { + return true, nil + } + } + return false, nil +} + +func workflowV4GrantRoleForEnrollment(role string) (string, error) { + switch role { + case "participant": + return access.RoleParticipant, nil + case "release-signer": + return access.RoleRelease, nil + case "auditor": + return access.RoleAuditor, nil + case "public-witness": + return access.RoleWitness, nil + case "mirror-operator": + return access.RoleMirror, nil + default: + return "", fmt.Errorf("role %q cannot receive an enrollment upload grant", role) + } +} + +func workflowV4LocalEnrollment(work, identity string, inspector transcript.Inspector, expected transcript.ExpectedEnrollment, ceremonyID string) (string, error) { + base := filepath.Join(work, "my-enrollment") + record := filepath.Join(base, "canonical.json") + signature := filepath.Join(base, "enrollment.sig") + disclosure := filepath.Join(base, "enrollments", identity, "disclosure.txt") + inspection, err := inspector.Enrollment(record, signature) + if err != nil { + return "", fmt.Errorf("authenticate retained enrollment: %w", err) + } + if inspection.CeremonyID != ceremonyID || inspection.Identity != expected.Identity || inspection.Role != expected.Role || inspection.RoleIndex != expected.RoleIndex { + return "", errors.New("retained enrollment differs from the signed assignment") + } + sha, size, err := transcript.DigestFile(disclosure) + if err != nil { + return "", err + } + if inspection.IndependenceDisclosure.Name != "enrollments/"+identity+"/disclosure.txt" || inspection.IndependenceDisclosure.Digest.SHA256 != sha || inspection.IndependenceDisclosure.Digest.Size != size { + return "", errors.New("retained disclosure differs from the signed enrollment") + } + return base, nil +} + +func runWorkflowV4OwnEnrollmentUpload(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, profile guidedProfile, config access.StorageConfig, inspector transcript.Inspector, identity setupIdentity) error { + expected, err := workflowV4ExpectedEnrollment(protocol, identity.ID) + if err != nil { + return err + } + base, err := workflowV4LocalEnrollment(profile.Work, identity.ID, inspector, expected, protocol.Definition.CeremonyID) + if err != nil { + return err + } + grantPath, err := ui.required("Absolute path to the private enrollment upload grant received from the coordinator or Tessera", "") + if err != nil { + return err + } + if !filepath.IsAbs(grantPath) || filepath.Clean(grantPath) != grantPath { + return errors.New("private grant path must be absolute and clean") + } + grant, err := loadStorageFirstGrant(grantPath) + if err != nil { + return err + } + destination := storagefirst.GrantDestination{Provider: config.Provider, Endpoint: config.Endpoint, Region: config.Region, InboxBucket: config.InboxBucket} + if err := storagefirst.ValidateEnrollmentGrantV4At(snapshot, protocol, identity.ID, expected.Role, expected.RoleIndex, grant, destination, time.Now().UTC()); err != nil { + return err + } + paths := map[string]string{ + "enrollment.json": filepath.Join(base, "canonical.json"), + "enrollment.sig": filepath.Join(base, "enrollment.sig"), + "disclosure.txt": filepath.Join(base, "enrollments", identity.ID, "disclosure.txt"), + } + sources := map[string]state.ContentRef{} + for name, path := range paths { + ref, err := workflowV4LocalRef(name, path) + if err != nil { + return err + } + sources[name] = state.ContentRef{Name: name, SHA256: ref.Digest.SHA256, Size: ref.Digest.Size} + } + temporary := filepath.Join(profile.Work, "workflow-v4", "temporary") + if err := ensureWorkflowV4Directory(profile.Work, temporary); err != nil { + return err + } + if err := ui.confirm("Upload only your signed public enrollment and disclosure; this does not upload your signing key", "UPLOAD ENROLLMENT"); err != nil { + return err + } + scope := storagefirst.DeliveryScope{CeremonyID: grant.CeremonyID, AttemptID: grant.AttemptID, Kind: access.SubmissionKindEnrollment} + inventory := storagefirst.DeliveryInventory{"enrollment.json": 16 << 20, "enrollment.sig": 4096, "disclosure.txt": 1 << 20} + if err := storagefirst.UploadDelivery(storageFirstGrantClient(grant), scope, inventory, sources, paths, temporary); err != nil { + return err + } + fmt.Fprintln(ui.output, "Enrollment upload completed. It remains pending until the coordinator verifies it and publishes a signed checkpoint that records it.") + return nil +} + +func runWorkflowV4ParticipantEnrollment(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, participant access.RoleConfig, config access.StorageConfig, inspector transcript.Inspector) error { + expected, err := workflowV4ExpectedEnrollment(protocol, participant.IdentityID) + if err != nil { + return err + } + if expected.Role != "participant" { + return errors.New("participant profile has a different signed enrollment role") + } + base := filepath.Join(filepath.Dir(participant.Root), "..", "my-enrollment") + base = filepath.Clean(base) + record := filepath.Join(base, "canonical.json") + signature := filepath.Join(base, "enrollment.sig") + disclosure := filepath.Join(base, "enrollments", participant.IdentityID, "disclosure.txt") + inspection, err := inspector.Enrollment(record, signature) + if err != nil { + return fmt.Errorf("authenticate retained enrollment: %w", err) + } + if inspection.CeremonyID != protocol.Definition.CeremonyID || inspection.Identity != expected.Identity || inspection.Role != expected.Role || inspection.RoleIndex != expected.RoleIndex { + return errors.New("retained enrollment differs from the signed assignment") + } + sha, size, err := transcript.DigestFile(disclosure) + if err != nil { + return err + } + if inspection.IndependenceDisclosure.Name != "enrollments/"+participant.IdentityID+"/disclosure.txt" || inspection.IndependenceDisclosure.Digest.SHA256 != sha || inspection.IndependenceDisclosure.Digest.Size != size { + return errors.New("retained disclosure differs from the signed enrollment") + } + grantPath, err := ui.required("Absolute path to the private enrollment upload grant received from the coordinator or Tessera", "") + if err != nil { + return err + } + if !filepath.IsAbs(grantPath) || filepath.Clean(grantPath) != grantPath { + return errors.New("private grant path must be absolute and clean") + } + grant, err := loadStorageFirstGrant(grantPath) + if err != nil { + return err + } + destination := storagefirst.GrantDestination{Provider: config.Provider, Endpoint: config.Endpoint, Region: config.Region, InboxBucket: config.InboxBucket} + if err := storagefirst.ValidateEnrollmentGrantV4At(snapshot, protocol, participant.IdentityID, expected.Role, expected.RoleIndex, grant, destination, time.Now().UTC()); err != nil { + return err + } + paths := map[string]string{"enrollment.json": record, "enrollment.sig": signature, "disclosure.txt": disclosure} + sources := map[string]state.ContentRef{} + for name, path := range paths { + ref, err := workflowV4LocalRef(name, path) + if err != nil { + return err + } + sources[name] = state.ContentRef{Name: name, SHA256: ref.Digest.SHA256, Size: ref.Digest.Size} + } + temporary := filepath.Join(filepath.Dir(participant.Root), "..", "workflow-v4", "temporary") + temporary = filepath.Clean(temporary) + if err := ensureWorkflowV4Directory(filepath.Clean(filepath.Join(filepath.Dir(participant.Root), "..")), temporary); err != nil { + return err + } + if err := ui.confirm("Upload only your signed public enrollment and disclosure; this does not upload your signing key", "UPLOAD ENROLLMENT"); err != nil { + return err + } + scope := storagefirst.DeliveryScope{CeremonyID: grant.CeremonyID, AttemptID: grant.AttemptID, Kind: access.SubmissionKindEnrollment} + inventory := storagefirst.DeliveryInventory{"enrollment.json": 16 << 20, "enrollment.sig": 4096, "disclosure.txt": 1 << 20} + if err := storagefirst.UploadDelivery(storageFirstGrantClient(grant), scope, inventory, sources, paths, temporary); err != nil { + return err + } + fmt.Fprintln(ui.output, "Enrollment upload completed. It remains pending until the coordinator verifies it and publishes a signed checkpoint that records it.") + return nil +} + +func runWorkflowV4CoordinatorEnrollment(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, config access.StorageConfig, online, signer guidedProfile, inspector transcript.Inspector, view storagefirst.TurnViewV4, progress workflowV4CoordinatorProgress) error { + if progress.EnrollmentExpected == nil || progress.EnrollmentExpected.Identity.ID != view.Scope.ParticipantID { + return errors.New("signed participant enrollment assignment is missing") + } + return runWorkflowV4CoordinatorExpectedEnrollment(ui, snapshot, protocol, config, online, signer, inspector, *progress.EnrollmentExpected, progress) +} + +func runWorkflowV4CoordinatorExpectedEnrollment(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, config access.StorageConfig, online, signer guidedProfile, inspector transcript.Inspector, expected transcript.ExpectedEnrollment, progress workflowV4CoordinatorProgress) error { + if progress.EnrollmentExpected == nil || *progress.EnrollmentExpected != expected { + return errors.New("coordinator enrollment progress differs from the signed assignment") + } + if expected.Role == "coordinator" { + base, err := workflowV4LocalEnrollment(online.Work, expected.Identity.ID, inspector, expected, protocol.Definition.CeremonyID) + if err != nil { + return err + } + if err := ui.confirm("Verify and record your coordinator enrollment from this protected workspace", "RECORD COORDINATOR ENROLLMENT"); err != nil { + return err + } + return prepareAndCommitWorkflowV4Enrollment(snapshot, protocol, online, signer, expected, base) + } + if progress.EnrollmentGrant == nil { + grantRole, err := workflowV4GrantRoleForEnrollment(expected.Role) + if err != nil { + return err + } + attempt, err := randomID() + if err != nil { + return err + } + grantDir := filepath.Join(online.Work, "workflow-v4", "coordinator", "enrollments", expected.Identity.ID, "grants") + if err := os.MkdirAll(grantDir, 0o700); err != nil { + return err + } + outPath := filepath.Join(grantDir, attempt+".json") + if err := ui.confirm("Create private upload access for only this signed enrollment assignment", "CREATE ENROLLMENT GRANT"); err != nil { + return err + } + storagePath, _ := pathWithin(online.Work, filepath.Join(online.Work, "ceremony", "config", "relay-storage.json"), "/work") + out, _ := pathWithin(online.Work, outPath, "/work") + command := []string{"relay", "coordinator", "grant", "--storage", storagePath, "--role", grantRole, "--identity", expected.Identity.ID, "--credential-ttl", "1h", "--minimum-remaining", "15m", "--out", out, "--checkpoint-digest", snapshot.Head().Record.Digest.SHA256, "--submission-kind", access.SubmissionKindEnrollment, "--phase", "setup", "--index", strconv.Itoa(expected.RoleIndex), "--attempt-id", attempt} + if err := runWorkflowV4ProfileCommand(online, command, true); err != nil { + return err + } + fmt.Fprintf(ui.output, "Give this private enrollment grant only to %s: %s\nIt permits one immutable public-enrollment upload; it is not a signing key.\n", expected.Identity.ID, outPath) + return nil + } + if !regularPreparationFile(filepath.Join(progress.EnrollmentDir, "enrollment.json")) { + if err := ui.confirm("Check the private inbox for this exact enrollment attempt", "CHECK ENROLLMENT INBOX"); err != nil { + return err + } + storagePath, _ := pathWithin(online.Work, filepath.Join(online.Work, "ceremony", "config", "relay-storage.json"), "/work") + out, _ := pathWithin(online.Work, progress.EnrollmentDir, "/work") + command := []string{"relay", "coordinator", "fetch-enrollment-v4", "--storage", storagePath, "--attempt-id", progress.EnrollmentGrant.AttemptID, "--out-dir", out} + return runWorkflowV4ProfileCommand(online, command, true) + } + receivedRecord := filepath.Join(progress.EnrollmentDir, "enrollment.json") + receivedSignature := filepath.Join(progress.EnrollmentDir, "enrollment.sig") + verified, err := inspector.Enrollment(receivedRecord, receivedSignature) + if err != nil { + return fmt.Errorf("authenticate downloaded enrollment: %w", err) + } + if verified.CeremonyID != protocol.Definition.CeremonyID || verified.Identity != expected.Identity || verified.Role != expected.Role || verified.RoleIndex != expected.RoleIndex { + return errors.New("downloaded enrollment differs from the signed assignment") + } + if err := ui.confirm("Verify the signed enrollment and disclosure, then record them in the next signed ceremony update", "VERIFY AND RECORD ENROLLMENT"); err != nil { + return err + } + return prepareAndCommitWorkflowV4Enrollment(snapshot, protocol, online, signer, expected, progress.EnrollmentDir) +} + +func prepareAndCommitWorkflowV4Enrollment(snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, online, signer guidedProfile, expected transcript.ExpectedEnrollment, received string) error { + work := online.Work + stagingParent := filepath.Join(work, "workflow-v4", "staging") + if err := ensureWorkflowV4Directory(work, stagingParent); err != nil { + return err + } + temporary, err := os.MkdirTemp(stagingParent, "enrollment-"+expected.Identity.ID+"-") + if err != nil { + return err + } + stage := filepath.Join(temporary, "artifacts") + refs := snapshot.StructuralFiles() + seen := map[string]bool{} + for _, ref := range refs { + seen[ref.Name] = true + } + for _, ref := range snapshot.Files() { + if !seen[ref.Name] { + refs = append(refs, ref) + seen[ref.Name] = true + } + } + publicRoot := filepath.Join(work, "ceremony", "public") + if err := stageWorkflowV4Snapshot(work, publicRoot, stage, refs); err != nil { + return err + } + enrollmentBase := filepath.Join("enrollments", expected.Identity.ID) + sources, err := workflowV4EnrollmentSourcePaths(received, expected.Identity.ID) + if err != nil { + return err + } + logical := map[string]string{ + filepath.Join(enrollmentBase, "enrollment.json"): sources["enrollment.json"], + filepath.Join(enrollmentBase, "enrollment.sig"): sources["enrollment.sig"], + filepath.Join(enrollmentBase, "disclosure.txt"): sources["disclosure.txt"], + } + for name, source := range logical { + limit := int64(16 << 20) + if strings.HasSuffix(name, ".sig") { + limit = 4096 + } else if strings.HasSuffix(name, "disclosure.txt") { + limit = 1 << 20 + } + raw, err := readTesseraRegularFile(source, limit, false) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(filepath.Join(stage, name)), 0o700); err != nil { + return err + } + if err := setupWriteBytesNewOrExact(filepath.Join(stage, name), raw, 0o600); err != nil { + return err + } + } + basis := strings.TrimPrefix(snapshot.Head().Record.Digest.SHA256, "sha256:")[:16] + outputRelative := filepath.Join("checkpoints", "enrollments", expected.Identity.ID, basis) + output := filepath.Join(stage, outputRelative) + if err := os.MkdirAll(filepath.Dir(output), 0o700); err != nil { + return err + } + container := func(profile guidedProfile, path, mount string) (string, error) { + return pathWithin(profile.Work, path, mount) + } + ceremony, err := container(signer, filepath.Join(stage, "ceremony.json"), "/work") + if err != nil { + return err + } + ceremonySig, _ := container(signer, filepath.Join(stage, "ceremony.sig"), "/work") + artifactRoot, _ := container(signer, stage, "/work") + headRecord, _ := container(signer, filepath.Join(stage, filepath.FromSlash(snapshot.Head().Record.Name)), "/work") + headSignature, _ := container(signer, filepath.Join(stage, filepath.FromSlash(snapshot.Head().Signature.Name)), "/work") + record, _ := container(signer, filepath.Join(stage, enrollmentBase, "enrollment.json"), "/work") + signature, _ := container(signer, filepath.Join(stage, enrollmentBase, "enrollment.sig"), "/work") + disclosure, _ := container(signer, filepath.Join(stage, enrollmentBase, "disclosure.txt"), "/work") + out, _ := container(signer, output, "/work") + coordinatorKey, err := pathWithin(signer.Trust, filepath.Join(online.Trust, "setup-coordinator.hex"), "/trust") + if err != nil { + return err + } + command := []string{"mpc-ceremony", "checkpoint", "record-v4", "--ceremony", ceremony, "--ceremony-signature", ceremonySig, "--coordinator-public-key-file", coordinatorKey, "--artifact-root", artifactRoot, "--checkpoint", headRecord, "--checkpoint-signature", headSignature, "--transition", "enrollment-recorded", "--record", record, "--record-signature", signature, "--evidence", disclosure, "--coordinator-signing-key", "/keys/signing.hex", "--out-dir", out} + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + for _, name := range []string{filepath.Join(enrollmentBase, "enrollment.json"), filepath.Join(enrollmentBase, "enrollment.sig"), filepath.Join(enrollmentBase, "disclosure.txt"), filepath.Join(outputRelative, "checkpoint.json"), filepath.Join(outputRelative, "checkpoint.sig")} { + source := filepath.Join(stage, name) + limit := int64(16 << 20) + if strings.HasSuffix(name, ".sig") { + limit = 4096 + } else if strings.HasSuffix(name, "disclosure.txt") { + limit = 1 << 20 + } + raw, err := readTesseraRegularFile(source, limit, false) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(filepath.Join(publicRoot, name)), 0o700); err != nil { + return err + } + if err := setupWriteBytesNewOrExact(filepath.Join(publicRoot, name), raw, 0o600); err != nil { + return err + } + } + return runWorkflowV4CommitCommand(online, filepath.Join(publicRoot, outputRelative)) +} + +// Local enrollment authoring uses proof-tool's canonical.json plus its nested +// disclosure path. Storage transport deliberately normalizes those public +// bytes to enrollment.json, enrollment.sig and disclosure.txt. Accept exactly +// one complete layout and never infer one file from a mixture of both. +func workflowV4EnrollmentSourcePaths(root, identity string) (map[string]string, error) { + localRecord := filepath.Join(root, "canonical.json") + transportRecord := filepath.Join(root, "enrollment.json") + local := regularPreparationFile(localRecord) + transport := regularPreparationFile(transportRecord) + if local == transport { + if local { + return nil, errors.New("enrollment directory ambiguously contains local and transport record names") + } + return nil, errors.New("enrollment directory has no canonical or transported record") + } + if local { + return map[string]string{ + "enrollment.json": localRecord, + "enrollment.sig": filepath.Join(root, "enrollment.sig"), + "disclosure.txt": filepath.Join(root, "enrollments", identity, "disclosure.txt"), + }, nil + } + return map[string]string{ + "enrollment.json": transportRecord, + "enrollment.sig": filepath.Join(root, "enrollment.sig"), + "disclosure.txt": filepath.Join(root, "disclosure.txt"), + }, nil +} diff --git a/cmd/relay/workflow_v4_enrollment_test.go b/cmd/relay/workflow_v4_enrollment_test.go new file mode 100644 index 0000000..c6e45f8 --- /dev/null +++ b/cmd/relay/workflow_v4_enrollment_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/zksecurity/relay/internal/access" +) + +func TestWorkflowV4EnrollmentGrantRolesAreExplicit(t *testing.T) { + for role, want := range map[string]string{ + "participant": access.RoleParticipant, + "release-signer": access.RoleRelease, + "auditor": access.RoleAuditor, + "public-witness": access.RoleWitness, + "mirror-operator": access.RoleMirror, + } { + got, err := workflowV4GrantRoleForEnrollment(role) + if err != nil || got != want { + t.Fatalf("role %s = %q, %v", role, got, err) + } + } + if _, err := workflowV4GrantRoleForEnrollment("coordinator"); err == nil { + t.Fatal("coordinator enrollment unexpectedly received a transport grant") + } +} + +func TestWorkflowV4EnrollmentSourcePathsDistinguishLocalAndTransportLayouts(t *testing.T) { + local := t.TempDir() + if err := os.WriteFile(filepath.Join(local, "canonical.json"), []byte("record"), 0o600); err != nil { + t.Fatal(err) + } + paths, err := workflowV4EnrollmentSourcePaths(local, "participant-01") + if err != nil { + t.Fatal(err) + } + wantDisclosure := filepath.Join(local, "enrollments", "participant-01", "disclosure.txt") + if paths["enrollment.json"] != filepath.Join(local, "canonical.json") || paths["disclosure.txt"] != wantDisclosure { + t.Fatalf("local authoring layout = %#v", paths) + } + + transport := t.TempDir() + if err := os.WriteFile(filepath.Join(transport, "enrollment.json"), []byte("record"), 0o600); err != nil { + t.Fatal(err) + } + paths, err = workflowV4EnrollmentSourcePaths(transport, "participant-01") + if err != nil { + t.Fatal(err) + } + if paths["enrollment.json"] != filepath.Join(transport, "enrollment.json") || paths["disclosure.txt"] != filepath.Join(transport, "disclosure.txt") { + t.Fatalf("transport layout = %#v", paths) + } + if err := os.WriteFile(filepath.Join(transport, "canonical.json"), []byte("duplicate"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := workflowV4EnrollmentSourcePaths(transport, "participant-01"); err == nil { + t.Fatal("accepted ambiguous mixed enrollment layout") + } +} diff --git a/cmd/relay/workflow_v4_erasure.go b/cmd/relay/workflow_v4_erasure.go new file mode 100644 index 0000000..9114cc2 --- /dev/null +++ b/cmd/relay/workflow_v4_erasure.go @@ -0,0 +1,178 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "time" + + "github.com/zksecurity/relay/internal/transcript" +) + +// executePreparedErasure is the production entry point. Tests inject dependencies +// only into runPreparedErasure; callers here cannot substitute a verifier runner. +func (j *workflowV4Journal) executePreparedErasure(id, scopeFile, dockerCLI string) error { + op, err := j.pending() + if err != nil { + return err + } + if op == nil || op.Plan.ID != id || op.Plan.Kind != "attest-erasure" { + return errors.New("prepared cleanup operation required") + } + if !filepath.IsAbs(dockerCLI) || filepath.Clean(dockerCLI) != dockerCLI { + return errors.New("exact local Docker CLI path required") + } + client := osDockerCommandClient{binary: dockerCLI} + var receipt dockerLifecycleReceipt + if len(op.Plan.Outputs) != 2 { + return errors.New("cleanup output pair required") + } + if err := readWorkflowV4JSON(filepath.Join(filepath.Dir(op.Plan.Outputs[0]), dockerLifecycleLogName), &receipt); err != nil { + return err + } + i, err := workflowV4CandidateInspector(op.Plan, j.state.Marker.Binding, scopeFile, client, receipt.Daemon) + if err != nil { + return err + } + return j.runPreparedErasure(id, i, scopeFile, client) +} + +// runPreparedErasure executes only an already reviewed, persisted cleanup +// operation. The inspector must use the approved contributor runtime. It never +// supplies the operator confirmation or retries a started signing operation. +func (j *workflowV4Journal) runPreparedErasure(id string, inspector transcript.Inspector, scopeFile string, client dockerCommandClient) error { + op, err := j.pending() + if err != nil { + return err + } + if op == nil || op.Plan.ID != id || op.Plan.Kind != "attest-erasure" || op.Status != "prepared" || client == nil { + return errors.New("a prepared cleanup-signing operation is required") + } + p := op.Plan + b := j.state.Marker.Binding + if err := validateWorkflowV4Plan(p, b); err != nil { + return err + } + if err := workflowV4InputsMatch(p); err != nil { + return err + } + flags := map[string]string{} + for n := 3; n < len(p.Command); n += 2 { + flags[p.Command[n]] = p.Command[n+1] + } + host := func(container string) (string, error) { + for destination, source := range p.Runtime.Mounts { + if mapped, err := pathWithin(destination, container, source); err == nil { + return mapped, nil + } + } + return "", errors.New("cleanup path has no saved mount") + } + paths := map[string]string{} + for _, flag := range []string{"--ceremony", "--ceremony-signature", "--coordinator-public-key-file", "--participant-signing-key", "--candidate-dir"} { + paths[flag], err = host(flags[flag]) + if err != nil { + return err + } + } + if inspector.Runner == nil || inspector.CeremonyPath != paths["--ceremony"] || inspector.CeremonySignaturePath != paths["--ceremony-signature"] || inspector.CoordinatorPublicKeyPath != paths["--coordinator-public-key-file"] { + return errors.New("cleanup requires the exact definition and container-backed inspector") + } + var chain, signature string + for _, input := range p.Inputs { + if input.Ref == p.Predecessor.Record { + chain = input.Path + } + if input.Ref == p.Predecessor.Signature { + signature = input.Path + } + } + if chain == "" || signature == "" { + return errors.New("cleanup lacks the exact predecessor") + } + if _, err := inspector.ComputationOutputV4(chain, signature, scopeFile, paths["--candidate-dir"], p.Scope, p.Predecessor); err != nil { + return err + } + var receipt dockerLifecycleReceipt + if err := readWorkflowV4JSON(filepath.Join(paths["--candidate-dir"], dockerLifecycleLogName), &receipt); err != nil { + return err + } + contributor := b.Runtimes["contributor"] + if err := j.validateCleanupContribution(p, receipt, paths["--candidate-dir"]); err != nil { + return err + } + if err := validateWorkflowV4LifecycleTimes(receipt); err != nil { + return err + } + when, _ := time.Parse(time.RFC3339, flags["--destroyed-at"]) + confirmed, confirmErr := time.Parse(time.RFC3339Nano, receipt.ConfirmedAt) + if receipt.Schema != dockerLifecycleSchema || receipt.Image != contributor.Image || receipt.Platform != contributor.Platform || !receipt.RemovalVerified || receipt.ExitCode != 0 || !validContainerID(receipt.ContainerID) || !verifiedDaemonFacts(receipt.Daemon) || !verifiedLifecycleFacts(receipt.Security) || !verifiedHostSwapStatus(runtime.GOOS, receipt.HostSwapStatus) || receipt.ParticipantConfirmation != "CLEANUP PRECAUTIONS CONFIRMED" || confirmErr != nil || confirmed.IsZero() || receipt.ErasureDestroyedAt != flags["--destroyed-at"] { + return errors.New("cleanup requires the original verified lifecycle and recorded confirmation/time") + } + d := &dockerDriver{image: p.Runtime.Image, platform: p.Runtime.Platform, definition: paths["--ceremony"], definitionSig: paths["--ceremony-signature"], coordinatorKey: paths["--coordinator-public-key-file"], signingKey: paths["--participant-signing-key"], client: client, daemon: receipt.Daemon} + if err := validateLocalDockerEndpoint(receipt.Daemon.Endpoint); err != nil { + return err + } + d.client = client.BindHost(receipt.Daemon.Endpoint) + if err := d.authenticateDaemon(); err != nil { + return err + } + platform, _, err := d.client.Output("image", "inspect", "--format", "{{.Os}}/{{.Architecture}}", d.image) + if err != nil || strings.TrimSpace(string(platform)) != d.platform { + return errors.New("original cleanup signing image/platform is unavailable") + } + stdout, _, err := d.client.Output("container", "ls", "--all", "--no-trunc", "--filter", "id="+receipt.ContainerID, "--format", "{{.ID}}") + if err != nil || strings.TrimSpace(string(stdout)) != "" { + return errors.New("original contributor absence could not be confirmed") + } + command, err := d.erasureCommand(roleOpts{phase: p.Scope.Phase, role: p.Scope.ParticipantID, outDir: paths["--candidate-dir"]}, when) + if err != nil { + return err + } + // Retain the exact rewritten child invocation before the running boundary. + intentPath := filepath.Join(b.Work, "workflow-v4", "execution-"+id+".json") + if err := validateCommitLocalPath(intentPath); err != nil { + return err + } + if _, err := os.Lstat(intentPath); errors.Is(err, os.ErrNotExist) { + if err := writeJSONNoReplace(intentPath, command, 0600); err != nil { + return err + } + } else { + var retained []string + if err := readWorkflowV4JSON(intentPath, &retained); err != nil { + return err + } + if !reflect.DeepEqual(retained, command) { + return errors.New("cleanup invocation differs from retained intent") + } + } + return j.runPrepared(id, func(workflowV4OperationPlan) error { + return d.client.Attached(os.Stdout, os.Stderr, command...) + }) +} + +func validateWorkflowV4LifecycleTimes(r dockerLifecycleReceipt) error { + return validateWorkflowV4OrderedTimes(r.ExecutionMode, []string{r.CreatedAt, r.StartedAt, r.ExitedAt, r.RemovedAt, r.ConfirmedAt, r.ErasureDestroyedAt}) +} + +func validateWorkflowV4OrderedTimes(mode string, values []string) error { + if mode != dockerExecutionMode { + return errors.New("cleanup lifecycle must describe Docker execution") + } + var previous time.Time + for _, value := range values { + current, err := time.Parse(time.RFC3339Nano, value) + if err != nil || current.IsZero() || !strings.HasSuffix(value, "Z") { + return errors.New("cleanup lifecycle requires UTC timestamps") + } + if !previous.IsZero() && current.Before(previous) { + return errors.New("cleanup lifecycle timestamps are out of order") + } + previous = current + } + return nil +} diff --git a/cmd/relay/workflow_v4_erasure_test.go b/cmd/relay/workflow_v4_erasure_test.go new file mode 100644 index 0000000..7ca630b --- /dev/null +++ b/cmd/relay/workflow_v4_erasure_test.go @@ -0,0 +1,221 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/zksecurity/relay/internal/transcript" +) + +type erasureClientV4 struct { + *dockerClientFake + calls int + fail bool +} + +func (f *erasureClientV4) BindHost(host string) dockerCommandClient { f.host = host; return f } +func (f *erasureClientV4) Attached(io.Writer, io.Writer, ...string) error { + f.calls++ + if f.fail { + return errors.New("lost child response") + } + return nil +} + +func TestWorkflowV4ErasureExecutionDoesNotReplay(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(map[bool]string{false: "returned", true: "interrupted"}[fail], func(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + p := workflowV4TestPlan(t, binding) + prior := p + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + if err := j.prepare(prior); err != nil { + t.Fatal(err) + } + if err := j.runPrepared(prior.ID, func(workflowV4OperationPlan) error { return nil }); err != nil { + t.Fatal(err) + } + if err := j.reconcile(prior.ID, func(workflowV4OperationPlan) error { return nil }); err != nil { + t.Fatal(err) + } + oldRoot := filepath.Join(binding.Work, "workflow-v4", "inputs", p.ID) + p.ID = strings.Repeat("2", 32) + newRoot := filepath.Join(binding.Work, "workflow-v4", "inputs", p.ID) + p.Inputs = append([]workflowV4Input(nil), p.Inputs...) + for n, in := range p.Inputs { + if !strings.HasPrefix(in.Path, oldRoot+string(filepath.Separator)) { + continue + } + data, err := os.ReadFile(in.Path) + if err != nil { + t.Fatal(err) + } + p.Inputs[n].Path = strings.Replace(in.Path, oldRoot, newRoot, 1) + if err := os.MkdirAll(filepath.Dir(p.Inputs[n].Path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p.Inputs[n].Path, data, 0600); err != nil { + t.Fatal(err) + } + } + p.Kind, p.Runtime = "attest-erasure", binding.Runtimes["signer"] + root := "/work/workflow-v4/inputs/" + p.ID + p.Command = []string{"mpc-ceremony", "phase1", "attest-erasure", "--ceremony", root + "/ceremony.json", "--ceremony-signature", root + "/ceremony.sig", "--coordinator-public-key-file", "/trust/coordinator.hex", "--participant-id", p.Scope.ParticipantID, "--participant-signing-key", "/keys/signing.hex", "--candidate-dir", "/work/candidate", "--destroyed-at", "2026-09-16T00:00:00Z"} + candidate := filepath.Join(binding.Work, "candidate") + if err := os.Mkdir(candidate, 0700); err != nil { + t.Fatal(err) + } + p.Outputs = []string{filepath.Join(candidate, "erasure.json"), filepath.Join(candidate, "erasure.sig")} + // Obtain realistic lifecycle facts from the existing isolated-driver fixture. + o, pos, driver, original := dockerContributionFixture(t) + o.operationID = prior.ID + driver.executionIntentPath = workflowV4ContributorIntentPath(binding.Work, prior.ID) + if err := runNextAt(o, pos, time.Now()); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(o.outDir, dockerLifecycleLogName)) + if err != nil { + t.Fatal(err) + } + var receipt dockerLifecycleReceipt + if err := json.Unmarshal(raw, &receipt); err != nil { + t.Fatal(err) + } + receipt.Image, receipt.Platform = p.Runtime.Image, p.Runtime.Platform + var intent dockerActiveState + if err := readWorkflowV4JSON(driver.executionIntentPath, &intent); err != nil { + t.Fatal(err) + } + intent.Image = receipt.Image + if err := writeJSONAtomic(driver.executionIntentPath, intent, 0600); err != nil { + t.Fatal(err) + } + destination := sha256.Sum256([]byte(candidate)) + receipt.CandidateDirectorySHA256 = fmt.Sprintf("sha256:%x", destination) + if runtime.GOOS == "darwin" { + receipt.HostSwapStatus = dockerMacSwapUnassessed + } + receipt.ParticipantConfirmation = "CLEANUP PRECAUTIONS CONFIRMED" + receipt.ConfirmedAt, receipt.ErasureDestroyedAt = "2026-09-16T00:00:00Z", "2026-09-16T00:00:00Z" + receipt.CreatedAt, receipt.StartedAt = "2026-09-15T23:59:57Z", "2026-09-15T23:59:58Z" + receipt.ExitedAt, receipt.RemovedAt = "2026-09-15T23:59:59Z", "2026-09-16T00:00:00Z" + raw, _ = json.Marshal(receipt) + files := []transcript.ArtifactRef{} + for _, name := range []string{"attestation.json", "attestation.sig", "contribution.bin", dockerLifecycleLogName} { + contents := name + if name == dockerLifecycleLogName { + contents = string(raw) + } + path := filepath.Join(candidate, name) + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + ref := workflowV4TestRef(name, contents) + p.Inputs = append(p.Inputs, workflowV4Input{Path: path, Ref: ref}) + if name != dockerLifecycleLogName { + files = append(files, ref) + } + } + if err := os.WriteFile(filepath.Join(p.Runtime.Mounts["/keys"], "signing.hex"), []byte("synthetic-key"), 0600); err != nil { + t.Fatal(err) + } + var ceremonyPath, ceremonySignaturePath, coordinatorKeyPath string + for _, input := range p.Inputs { + switch input.Ref { + case binding.Definition.Record: + ceremonyPath = input.Path + case binding.Definition.Signature: + ceremonySignaturePath = input.Path + } + if filepath.Base(input.Path) == "coordinator.hex" { + coordinatorKeyPath = input.Path + } + } + i := transcript.Inspector{Executable: "approved-test-tool", CeremonyPath: ceremonyPath, CeremonySignaturePath: ceremonySignaturePath, CoordinatorPublicKeyPath: coordinatorKeyPath, TranscriptRoot: filepath.Dir(ceremonyPath)} + i.Runner = func(string, ...string) ([]byte, []byte, error) { + result := map[string]any{"schema": "proof-tool-mpc-command-result-v1", "ok": true, "command": "inspect computation-output-v4", "computation_output_v4": transcript.ComputationOutputInspectionV4{Schema: "proof-tool-mpc-computation-output-inspection-v4", Depth: "computation-signatures-and-digests", SignaturesVerified: true, PayloadDigestVerified: true, Output: transcript.ComputationOutputFactsV4{Scope: p.Scope, Predecessor: p.Predecessor, Files: files}}} + b, err := json.Marshal(result) + return b, nil, err + } + if err := j.prepare(p); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*dockerLifecycleReceipt){ + func(r *dockerLifecycleReceipt) { r.OperationID = strings.Repeat("f", 32) }, + func(r *dockerLifecycleReceipt) { r.CandidateDirectorySHA256 = "sha256:" + strings.Repeat("f", 64) }, + func(r *dockerLifecycleReceipt) { r.CreateArgsSHA256 = "sha256:" + strings.Repeat("f", 64) }, + func(r *dockerLifecycleReceipt) { r.WorkspaceID = "sha256:" + strings.Repeat("f", 64) }, + } { + bad := receipt + mutate(&bad) + if err := j.validateCleanupContribution(p, bad, candidate); err == nil { + t.Fatal("accepted unrelated lifecycle") + } + } + f := &erasureClientV4{dockerClientFake: original, fail: fail} + f.platform = p.Runtime.Platform + wrong := i + wrong.CoordinatorPublicKeyPath = "another-key" + if err := j.runPreparedErasure(p.ID, wrong, "scope.json", f); err == nil || f.calls != 0 { + t.Fatal("started cleanup with another trust anchor") + } + err = j.runPreparedErasure(p.ID, i, "scope.json", f) + if (fail && (err == nil || !strings.Contains(err.Error(), "lost child response"))) || (!fail && err != nil) || f.calls != 1 { + t.Fatalf("child boundary: calls=%d err=%v", f.calls, err) + } + op, err := j.pending() + want := "returned-needs-verification" + if fail { + want = "running" + } + if err != nil || op == nil || op.Status != want { + t.Fatalf("unexpected completion claim: %+v %v", op, err) + } + if err := j.close(); err != nil { + t.Fatal(err) + } + j, err = openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + if err := j.runPreparedErasure(p.ID, i, "scope.json", f); err == nil || f.calls != 1 { + t.Fatal("replayed uncertain cleanup") + } + }) + } +} + +func TestWorkflowV4LifecycleChronology(t *testing.T) { + r := dockerLifecycleReceipt{ExecutionMode: dockerExecutionMode, CreatedAt: "2026-09-16T00:00:00Z", StartedAt: "2026-09-16T00:00:01Z", ExitedAt: "2026-09-16T00:00:02Z", RemovedAt: "2026-09-16T00:00:03.1Z", ConfirmedAt: "2026-09-16T00:00:03.2Z", ErasureDestroyedAt: "2026-09-16T00:00:04Z"} + if err := validateWorkflowV4LifecycleTimes(r); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*dockerLifecycleReceipt){ + func(r *dockerLifecycleReceipt) { r.ExecutionMode = "native" }, + func(r *dockerLifecycleReceipt) { r.ErasureDestroyedAt = "2026-09-16T00:00:03Z" }, + func(r *dockerLifecycleReceipt) { r.ConfirmedAt = "2026-09-16T00:00:03.05Z" }, + func(r *dockerLifecycleReceipt) { r.RemovedAt = "2026-09-16T00:00:01Z" }, + func(r *dockerLifecycleReceipt) { r.ErasureDestroyedAt = "2026-09-16T00:00:02Z" }, + func(r *dockerLifecycleReceipt) { r.StartedAt = "2026-09-16T00:00:01+00:00" }, + } { + bad := r + mutate(&bad) + if err := validateWorkflowV4LifecycleTimes(bad); err == nil { + t.Fatal("accepted invalid lifecycle chronology") + } + } +} diff --git a/cmd/relay/workflow_v4_final_release.go b/cmd/relay/workflow_v4_final_release.go new file mode 100644 index 0000000..fb90926 --- /dev/null +++ b/cmd/relay/workflow_v4_final_release.go @@ -0,0 +1,251 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +func runWorkflowV4FinalReleaseLifecycle(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, online, signer guidedProfile) error { + stateView, err := snapshot.State() + if err != nil { + return err + } + if stateView.Progress.ReleaseReview == nil || stateView.Progress.FinalRelease != nil { + return errors.New("final release requires the current frozen review and no existing final release") + } + expected, err := workflowV4ReleaseSignerAssignment(protocol) + if err != nil { + return err + } + config, err := loadStorageConfig(filepath.Join(online.Work, "ceremony", "config", "relay-storage.json")) + if err != nil { + return err + } + root := filepath.Join(online.Work, "ceremony", "public") + releaseDir := filepath.Join(root, "final", "release") + grantPath := "" + if _, err := os.Lstat(releaseDir); errors.Is(err, os.ErrNotExist) { + grant, retainedPath, err := workflowV4CurrentReleaseGrant(snapshot, protocol, config, online.Work, expected.Identity.ID, time.Now().UTC()) + if err != nil { + return err + } + grantPath = retainedPath + if grant == nil { + return runWorkflowV4IssueReleaseGrant(ui, snapshot, config, online, expected) + } + received := filepath.Join(online.Work, "workflow-v4", "coordinator", "release", "received", grant.AttemptID) + if _, err := os.Lstat(received); errors.Is(err, os.ErrNotExist) { + if err := ui.confirm("Check the private inbox for the exact signed release package associated with the retained grant", "CHECK RELEASE INBOX"); err != nil { + return err + } + storagePath, _ := pathWithin(online.Work, filepath.Join(online.Work, "ceremony", "config", "relay-storage.json"), "/work") + out, _ := pathWithin(online.Work, received, "/work") + command := []string{"relay", "coordinator", "fetch-release-v4", "--storage", storagePath, "--attempt-id", grant.AttemptID, "--out-dir", out} + if err := runWorkflowV4ProfileCommand(online, command, true); err != nil { + return err + } + } else if err != nil { + return err + } + if err := ui.confirm("Authenticate the downloaded package against the signed release-signer assignment and frozen coordinator review, then record it", "VERIFY AND RECORD RELEASE"); err != nil { + return err + } + if err := runWorkflowV4VerifyReleasePackage(online, received, expected.Identity.KeyID); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(releaseDir), 0o700); err != nil { + return err + } + if err := os.Rename(received, releaseDir); err != nil { + return err + } + if err := syncDirectory(filepath.Dir(releaseDir)); err != nil { + return err + } + } else if err != nil { + return err + } else if err := runWorkflowV4VerifyReleasePackage(online, releaseDir, expected.Identity.KeyID); err != nil { + return fmt.Errorf("verify retained release package: %w", err) + } + _, _, paths, err := workflowV4ReleaseFiles(releaseDir) + if err != nil { + return err + } + record := filepath.Join(releaseDir, "manifest.json") + signature := filepath.Join(releaseDir, "manifest.sig") + evidence, err := workflowV4FinalReleaseEvidence(paths) + if err != nil { + return err + } + basis := strings.TrimPrefix(snapshot.Head().Record.Digest.SHA256, "sha256:")[:16] + outputDir := filepath.Join(root, "checkpoints", "final", "release-"+basis) + if _, err := os.Lstat(filepath.Join(outputDir, "checkpoint.json")); errors.Is(err, os.ErrNotExist) { + command, err := workflowV4RecordCommand(snapshot, online, signer, "final-release-recorded", record, signature, evidence, outputDir) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } else if err != nil { + return err + } + if err := runWorkflowV4CommitCommand(online, outputDir); err != nil { + return err + } + if grantPath != "" { + fmt.Fprintf(ui.output, "Final signed release checkpoint recorded. Retained private grant: %s. Public publication remains a separate action.\n", grantPath) + } else { + fmt.Fprintln(ui.output, "Final signed release checkpoint recorded. Public publication remains a separate action.") + } + return nil +} + +func workflowV4FinalReleaseEvidence(paths map[string]string) ([]string, error) { + names := []string{workflowV4ReleaseTranscriptFile, workflowV4ReleaseManifestPublicFile, workflowV4ReleaseChecksumsFile} + evidence := make([]string, 0, len(names)) + for _, name := range names { + path := paths[name] + if path == "" { + return nil, fmt.Errorf("verified release package lacks required checkpoint bootstrap %q", name) + } + evidence = append(evidence, path) + } + // Keep review output and generated commands deterministic. + sort.Strings(evidence) + return evidence, nil +} + +func workflowV4ReleaseSignerAssignment(protocol transcript.DefinitionProtocol) (transcript.ExpectedEnrollment, error) { + journey, err := protocol.Definition.RequireJourney() + if err != nil { + return transcript.ExpectedEnrollment{}, err + } + var result transcript.ExpectedEnrollment + for _, expected := range journey.RequiredEnrollments { + if expected.Role == "release-signer" { + if result.Identity.ID != "" { + return result, errors.New("signed definition has multiple release signers") + } + result = expected + } + } + if result.Identity.ID == "" { + return result, errors.New("signed definition has no release signer") + } + return result, nil +} + +func workflowV4CurrentReleaseGrant(snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, config access.StorageConfig, work, identity string, now time.Time) (*access.StorageFirstGrant, string, error) { + grantDir := filepath.Join(work, "workflow-v4", "coordinator", "release", "grants") + entries, err := os.ReadDir(grantDir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, "", err + } + destination := storagefirst.GrantDestination{Provider: config.Provider, Endpoint: config.Endpoint, Region: config.Region, InboxBucket: config.InboxBucket} + var selected *access.StorageFirstGrant + selectedPath := "" + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + path := filepath.Join(grantDir, entry.Name()) + grant, err := loadStorageFirstGrant(path) + if err != nil { + return nil, "", fmt.Errorf("read retained release grant %s: %w", path, err) + } + if err := storagefirst.ValidateReleaseGrantV4At(snapshot, protocol, identity, grant, destination, now); err != nil { + continue + } + if selected == nil { + copy := grant + selected, selectedPath = ©, path + } else { + current, _ := time.Parse(time.RFC3339, selected.ExpiresAt) + candidate, _ := time.Parse(time.RFC3339, grant.ExpiresAt) + if candidate.After(current) { + copy := grant + selected, selectedPath = ©, path + } + } + } + return selected, selectedPath, nil +} + +func runWorkflowV4IssueReleaseGrant(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, config access.StorageConfig, online guidedProfile, expected transcript.ExpectedEnrollment) error { + metadata, err := snapshot.Enrollments() + if err != nil { + return err + } + var enrollment *transcript.CommittedEnrollmentMetadataV4 + for n := range metadata.Enrollments { + item := &metadata.Enrollments[n] + if item.Enrollment.Role == "release-signer" && item.Enrollment.Identity.ID == expected.Identity.ID { + enrollment = item + } + } + if enrollment == nil { + return errors.New("release signer enrollment is not committed in the current signed state") + } + attempt, err := randomID() + if err != nil { + return err + } + grantDir := filepath.Join(online.Work, "workflow-v4", "coordinator", "release", "grants") + if err := os.MkdirAll(grantDir, 0o700); err != nil { + return err + } + outPath := filepath.Join(grantDir, attempt+".json") + if err := ui.confirm("Create private upload access for only the authenticated release signer and this frozen review checkpoint", "CREATE RELEASE GRANT"); err != nil { + return err + } + root := filepath.Join(online.Work, "ceremony", "public") + storagePath, _ := pathWithin(online.Work, filepath.Join(online.Work, "ceremony", "config", "relay-storage.json"), "/work") + out, _ := pathWithin(online.Work, outPath, "/work") + record, _ := pathWithin(online.Work, filepath.Join(root, filepath.FromSlash(enrollment.Refs.Record.Name)), "/work") + signature, _ := pathWithin(online.Work, filepath.Join(root, filepath.FromSlash(enrollment.Refs.Signature.Name)), "/work") + command := []string{"relay", "coordinator", "grant", "--storage", storagePath, "--role", access.RoleRelease, "--identity", expected.Identity.ID, "--credential-ttl", "1h", "--minimum-remaining", "15m", "--enrollment", record, "--enrollment-signature", signature, "--out", out, "--checkpoint-digest", snapshot.Head().Record.Digest.SHA256, "--submission-kind", access.SubmissionKindRelease, "--phase", "release", "--index", strconv.Itoa(1), "--attempt-id", attempt} + if err := runWorkflowV4ProfileCommand(online, command, true); err != nil { + return err + } + fmt.Fprintf(ui.output, "Give this private release upload grant only to %s: %s\nIt permits one immutable package upload and contains temporary storage credentials; it is not a signing key.\n", expected.Identity.ID, outPath) + return nil +} + +func runWorkflowV4VerifyReleasePackage(online guidedProfile, releaseDir, keyID string) error { + root := filepath.Join(online.Work, "ceremony", "public") + mapWork := func(path string) (string, error) { return pathWithin(online.Work, path, "/work") } + ceremony, err := mapWork(filepath.Join(root, "ceremony.json")) + if err != nil { + return err + } + ceremonySig, _ := mapWork(filepath.Join(root, "ceremony.sig")) + keys, err := mapWork(releaseDir) + if err != nil { + return err + } + publicKey, err := mapWork(filepath.Join(releaseDir, "manifest-public-key.hex")) + if err != nil { + return err + } + keyName := "setup-coordinator.hex" + if online.Role == "release-signer" { + keyName = "coordinator-public-key.hex" + } + coordinatorKey, err := pathWithin(online.Trust, filepath.Join(online.Trust, keyName), "/trust") + if err != nil { + return err + } + command := []string{"mpc-ceremony", "release", "verify", "--ceremony", ceremony, "--ceremony-signature", ceremonySig, "--coordinator-public-key-file", coordinatorKey, "--keys-dir", keys, "--manifest-public-key-file", publicKey, "--signature-key-id", keyID} + return runWorkflowV4ProfileCommand(online, command, false) +} diff --git a/cmd/relay/workflow_v4_full_live_test.go b/cmd/relay/workflow_v4_full_live_test.go new file mode 100644 index 0000000..cb79105 --- /dev/null +++ b/cmd/relay/workflow_v4_full_live_test.go @@ -0,0 +1,757 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +type workflowV4LiveRole struct { + profile guidedProfile + signer guidedProfile + identity setupIdentity + inspector transcript.Inspector + journal *workflowV4Journal + participant *access.RoleConfig +} + +// TestV4LiveFullR2Journey is opt-in because it creates a uniquely identified +// two-phase rehearsal in the configured test buckets and waits for two real +// future Quicknet rounds. It drives the same role action functions as the +// normal coordinator, participant and release-signer guide. +func TestV4LiveFullR2Journey(t *testing.T) { + offlineImage := os.Getenv("RELAY_PREPARE_TEST_IMAGE") + onlineImage := os.Getenv("RELAY_V4_LIVE_ONLINE_IMAGE") + relayBinary := os.Getenv("RELAY_V4_LIVE_RELAY_BINARY") + configPath := os.Getenv("RELAY_V4_LIVE_R2_CONFIG") + credentialsPath := os.Getenv("RELAY_V4_LIVE_R2_CREDENTIALS") + parentPath := os.Getenv("RELAY_V4_LIVE_R2_PARENT") + controlPath := os.Getenv("RELAY_V4_LIVE_R2_CONTROL") + proofBinary := os.Getenv("RELAY_V4_LIVE_PROOF_BINARY") + 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") + } + 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") + } + } + previousExecutor := workflowV4ChildExecutor + workflowV4ChildExecutor = func(args []string) error { + command := exec.Command(relayBinary, args...) + command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr + return command.Run() + } + t.Cleanup(func() { workflowV4ChildExecutor = previousExecutor }) + rawConfig, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + base, err := access.Decode(rawConfig, access.StorageConfig.Validate) + if err != nil || base.Provider != "r2" { + t.Fatalf("live test requires an existing valid R2 configuration: %v", err) + } + platform, err := machineDockerPlatform() + if err != nil { + t.Fatal(err) + } + root := os.Getenv("RELAY_V4_LIVE_WORK_ROOT") + if root == "" { + root = t.TempDir() + } else { + if !filepath.IsAbs(root) || filepath.Clean(root) != root { + t.Fatal("live retained work root must be an absolute clean path") + } + if _, err := os.Lstat(root); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("live retained work root must be fresh: %v", err) + } + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + t.Logf("retaining live rehearsal workspace at %s", root) + } + settingsRoot := filepath.Join(root, "settings") + if err := os.MkdirAll(settingsRoot, 0o700); err != nil { + t.Fatal(err) + } + coordinator := newWorkflowV4LiveRole(t, root, "coordinator", "coordinator", onlineImage, offlineImage, platform) + participant := newWorkflowV4LiveRole(t, root, "participant", "participant-01", offlineImage, offlineImage, platform) + releaseSigner := newWorkflowV4LiveRole(t, root, "release-signer", "release-signer", onlineImage, offlineImage, platform) + // Coordinator setup aliases are derived from the ceremony name. Give every + // live run a fresh name so an old developer profile can never substitute a + // previously saved command for this run. + nameHash := sha256.Sum256([]byte(root)) + coordinator.profile.Name = fmt.Sprintf("v4-live-coordinator-%x", nameHash[:6]) + coordinator.signer.Name = offlineRoleAlias(coordinator.profile.Name, "coordinator") + coordinator.profile.Credentials = credentialsPath + coordinator.profile.R2Parent = parentPath + coordinator.profile.R2Control = controlPath + + w := setupFixture(t) + w.d.Name = coordinator.profile.Name + w.d.Work, w.d.Trust, w.d.Keys = coordinator.profile.Work, coordinator.profile.Trust, coordinator.profile.Keys + w.d.Identities = setupRoster{ + Coordinator: coordinator.identity, + ReleaseSigner: releaseSigner.identity, + Roster: []setupParticipant{{Identity: participant.identity}}, + } + w.d.Policy.Assurance = &setupAssurance{} + w.d.Policy.Phase1 = setupPhase{Participants: []string{participant.identity.ID}, Minimum: 1} + w.d.Policy.Phase2 = setupPhase{Participants: []string{participant.identity.ID}, Minimum: 1} + w.d.Policy.Beacon.Lead = 1 + digest, err := setupFileHash(proofBinary) + if err != nil { + t.Fatal(err) + } + w.d.Binaries = []setupBinary{{Path: proofBinary, SHA256: digest}} + w.draftPath = filepath.Join(w.d.Work, "coordinator-setup", "draft.json") + if err := os.MkdirAll(filepath.Dir(w.draftPath), 0o700); err != nil { + t.Fatal(err) + } + w.input = bufio.NewReader(strings.NewReader("INITIALIZE REHEARSAL\n")) + w.output = new(bytes.Buffer) + w.run = workflowV4LiveSetupRunner(t, offlineImage, platform, w.d) + if err := w.initialize(); err != nil { + t.Fatal(err) + } + copyWorkflowV4LiveFile(t, filepath.Join(coordinator.profile.Trust, "setup-coordinator.hex"), filepath.Join(coordinator.profile.Trust, "coordinator-public-key.hex")) + + ceremonyRoot := filepath.Join(coordinator.profile.Work, "ceremony", "public") + hostInspector := transcript.Inspector{ + Executable: proofBinary, CeremonyPath: filepath.Join(ceremonyRoot, "ceremony.json"), CeremonySignaturePath: filepath.Join(ceremonyRoot, "ceremony.sig"), + CoordinatorPublicKeyPath: filepath.Join(coordinator.profile.Trust, "setup-coordinator.hex"), TranscriptRoot: ceremonyRoot, + } + protocol, err := hostInspector.DefinitionProtocol() + if err != nil { + t.Fatal(err) + } + config := base + config.CeremonyID = protocol.Definition.CeremonyID + config.CeremonyPath = "/work/ceremony/public/ceremony.json" + config.CeremonySignature = "/work/ceremony/public/ceremony.sig" + config.CoordinatorPublicKey = "/trust/setup-coordinator.hex" + config.CeremonyBinary = "/usr/local/bin/mpc-ceremony" + writeWorkflowV4LiveConfig(t, coordinator.profile.Work, config) + if err := runWorkflowV4ProfileCommand(coordinator.profile, []string{ + "relay", "coordinator", "commit-v4", "--storage", "/work/ceremony/config/relay-storage.json", "--artifact-root", "/work/ceremony/public", + "--checkpoint", "/work/ceremony/public/checkpoints/initial/checkpoint.json", "--checkpoint-signature", "/work/ceremony/public/checkpoints/initial/checkpoint.sig", + "--ceremony", config.CeremonyPath, "--ceremony-signature", config.CeremonySignature, "--coordinator-key", config.CoordinatorPublicKey, "--ceremony-binary", config.CeremonyBinary, + }, true); err != nil { + t.Fatal(err) + } + + for _, role := range []*workflowV4LiveRole{&coordinator, &participant, &releaseSigner} { + prepareWorkflowV4LiveRole(t, role, coordinator, config, protocol, offlineImage, platform) + defer role.journal.close() + } + objects := store.Client{PublicBaseURL: config.PublishedBaseURL} + prepareWorkflowV4LiveEnrollment(t, coordinator) + prepareWorkflowV4LiveEnrollment(t, participant) + prepareWorkflowV4LiveEnrollment(t, releaseSigner) + + roles := map[string]*workflowV4LiveRole{ + coordinator.identity.ID: &coordinator, + participant.identity.ID: &participant, + releaseSigner.identity.ID: &releaseSigner, + } + for { + snapshot := syncWorkflowV4LiveRole(t, &coordinator, objects) + expected, err := workflowV4NextRequiredEnrollment(snapshot, protocol) + if err != nil { + t.Fatal(err) + } + if expected == nil { + break + } + progress, err := workflowV4CoordinatorEnrollmentProgressFor(snapshot, protocol, *expected, coordinator.journal.state.Marker.Binding, config, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if expected.Role == "coordinator" { + ui := workflowV4LiveUI("RECORD COORDINATOR ENROLLMENT\n") + if err := runWorkflowV4CoordinatorExpectedEnrollment(ui, snapshot, protocol, config, coordinator.profile, coordinator.signer, coordinator.inspector, *expected, progress); err != nil { + t.Fatal(err) + } + continue + } + ui := workflowV4LiveUI("CREATE ENROLLMENT GRANT\n") + if err := runWorkflowV4CoordinatorExpectedEnrollment(ui, snapshot, protocol, config, coordinator.profile, coordinator.signer, coordinator.inspector, *expected, progress); err != nil { + t.Fatal(err) + } + progress, err = workflowV4CoordinatorEnrollmentProgressFor(snapshot, protocol, *expected, coordinator.journal.state.Marker.Binding, config, time.Now().UTC()) + if err != nil || progress.EnrollmentGrant == nil { + t.Fatalf("missing enrollment grant: %+v %v", progress, err) + } + owner := roles[expected.Identity.ID] + ownerSnapshot := syncWorkflowV4LiveRole(t, owner, objects) + ui = workflowV4LiveUI(progress.EnrollmentGrantPath + "\nUPLOAD ENROLLMENT\n") + if err := runWorkflowV4OwnEnrollmentUpload(ui, ownerSnapshot, protocol, owner.profile, config, owner.inspector, owner.identity); err != nil { + t.Fatal(err) + } + ui = workflowV4LiveUI("CHECK ENROLLMENT INBOX\n") + if err := runWorkflowV4CoordinatorExpectedEnrollment(ui, snapshot, protocol, config, coordinator.profile, coordinator.signer, coordinator.inspector, *expected, progress); err != nil { + t.Fatal(err) + } + progress, err = workflowV4CoordinatorEnrollmentProgressFor(snapshot, protocol, *expected, coordinator.journal.state.Marker.Binding, config, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + ui = workflowV4LiveUI("VERIFY AND RECORD ENROLLMENT\n") + if err := runWorkflowV4CoordinatorExpectedEnrollment(ui, snapshot, protocol, config, coordinator.profile, coordinator.signer, coordinator.inspector, *expected, progress); err != nil { + t.Fatal(err) + } + } + + runWorkflowV4LiveTurn(t, objects, protocol, config, &coordinator, &participant, "phase1") + runWorkflowV4LiveLifecycle(t, objects, protocol, &coordinator, workflowV4ClosePhase1, "CLOSE PHASE1\n") + runWorkflowV4LiveBeacon(t, objects, protocol, &coordinator, workflowV4BeaconPhase1, "RECORD PHASE1 BEACON\n") + runWorkflowV4LiveLifecycle(t, objects, protocol, &coordinator, workflowV4SealPhase1, "SEAL PHASE1\n") + runWorkflowV4LiveLifecycle(t, objects, protocol, &coordinator, workflowV4StartPhase2, "START PHASE2\n") + participant.participant.Phase = "phase2" + runWorkflowV4LiveTurn(t, objects, protocol, config, &coordinator, &participant, "phase2") + runWorkflowV4LiveLifecycle(t, objects, protocol, &coordinator, workflowV4ClosePhase2, "CLOSE PHASE2\n") + runWorkflowV4LiveBeacon(t, objects, protocol, &coordinator, workflowV4BeaconPhase2, "RECORD PHASE2 BEACON\n") + runWorkflowV4LiveLifecycle(t, objects, protocol, &coordinator, workflowV4Finalize, "FINALIZE CANDIDATE\n") + runWorkflowV4LiveLifecycle(t, objects, protocol, &coordinator, workflowV4Review, "SIGN EVIDENCE BUNDLE\n") + + snapshot := syncWorkflowV4LiveRole(t, &coordinator, objects) + if err := runWorkflowV4CoordinatorLifecycle(workflowV4LiveUI("CREATE RELEASE GRANT\n"), workflowV4Release, snapshot, protocol, coordinator.profile, coordinator.signer, coordinator.inspector); err != nil { + t.Fatal(err) + } + grant, grantPath, err := workflowV4CurrentReleaseGrant(snapshot, protocol, config, coordinator.profile.Work, releaseSigner.identity.ID, time.Now().UTC()) + if err != nil || grant == nil { + t.Fatalf("missing release grant: %v", err) + } + releaseSnapshot := syncWorkflowV4LiveRole(t, &releaseSigner, objects) + progress, err := workflowV4ReleaseSignerProgressFor(releaseSigner.profile.Work) + if err != nil { + t.Fatal(err) + } + if err := runWorkflowV4ReleaseSignerAction(workflowV4LiveUI("SIGN RELEASE PACKAGE\n"), releaseSnapshot, protocol, config, releaseSigner.profile, releaseSigner.signer, releaseSigner.identity, progress); err != nil { + t.Fatal(err) + } + progress, err = workflowV4ReleaseSignerProgressFor(releaseSigner.profile.Work) + if err != nil { + t.Fatal(err) + } + if err := runWorkflowV4ReleaseSignerAction(workflowV4LiveUI(grantPath+"\nUPLOAD RELEASE PACKAGE\n"), releaseSnapshot, protocol, config, releaseSigner.profile, releaseSigner.signer, releaseSigner.identity, progress); err != nil { + t.Fatal(err) + } + snapshot = syncWorkflowV4LiveRole(t, &coordinator, objects) + if err := runWorkflowV4CoordinatorLifecycle(workflowV4LiveUI("CHECK RELEASE INBOX\nVERIFY AND RECORD RELEASE\n"), workflowV4Release, snapshot, protocol, coordinator.profile, coordinator.signer, coordinator.inspector); err != nil { + t.Fatal(err) + } + + freshRoot := filepath.Join(root, "fresh-client") + freshTranscript := filepath.Join(freshRoot, "ceremony", "public") + freshTrust := filepath.Join(freshRoot, "trust") + for _, name := range []string{"ceremony.json", "ceremony.sig"} { + 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, + } + highWater, err := state.OpenWorkspaceHighWater(filepath.Join(freshRoot, "high-water"), protocol.Definition.CeremonyID) + if err != nil { + t.Fatal(err) + } + terminal, err := storagefirst.SyncV4(objects, freshInspector, highWater, protocol.Definition.CeremonyID, freshRoot) + if err != nil { + t.Fatal(err) + } + stateView, err := terminal.State() + if err != nil || stateView.Progress.FinalRelease == nil { + t.Fatalf("fresh verifier did not reconstruct the final release: %+v %v", stateView.Progress, err) + } + t.Logf("completed and freshly reconstructed live R2 V4 ceremony %s at signed update %d", protocol.Definition.CeremonyID, stateView.Sequence) +} + +// 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, +// then finishes release signing/upload, coordinator acceptance, and a fresh +// public reconstruction. This keeps late integration failures from forcing a +// new ceremony merely to exercise the remaining release boundary. +func TestV4LiveResumeR2Release(t *testing.T) { + root := os.Getenv("RELAY_V4_LIVE_RESUME_ROOT") + relayBinary := os.Getenv("RELAY_V4_LIVE_RELAY_BINARY") + proofBinary := os.Getenv("RELAY_V4_LIVE_PROOF_BINARY") + credentialsPath := os.Getenv("RELAY_V4_LIVE_R2_CREDENTIALS") + parentPath := os.Getenv("RELAY_V4_LIVE_R2_PARENT") + controlPath := os.Getenv("RELAY_V4_LIVE_R2_CONTROL") + if root == "" || relayBinary == "" || proofBinary == "" || credentialsPath == "" || parentPath == "" || controlPath == "" { + t.Skip("set the retained V4 live root, Relay/proof-tool binaries, and three R2 credential paths") + } + for _, path := range []string{root, relayBinary, proofBinary, credentialsPath, parentPath, controlPath} { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + t.Fatal("live recovery paths must be absolute and clean") + } + } + previousExecutor := workflowV4ChildExecutor + workflowV4ChildExecutor = func(args []string) error { + command := exec.Command(relayBinary, args...) + command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr + return command.Run() + } + t.Cleanup(func() { workflowV4ChildExecutor = previousExecutor }) + + coordinatorRoot := filepath.Join(root, "coordinator") + ceremonyRoot := filepath.Join(coordinatorRoot, "work", "ceremony", "public") + hostInspector := transcript.Inspector{ + Executable: proofBinary, + CeremonyPath: filepath.Join(ceremonyRoot, "ceremony.json"), + CeremonySignaturePath: filepath.Join(ceremonyRoot, "ceremony.sig"), + CoordinatorPublicKeyPath: filepath.Join(coordinatorRoot, "trust", "setup-coordinator.hex"), + TranscriptRoot: ceremonyRoot, + } + protocol, err := hostInspector.DefinitionProtocol() + if err != nil { + t.Fatal(err) + } + configPath := filepath.Join(coordinatorRoot, "work", "ceremony", "config", "relay-storage.json") + rawConfig, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + config, err := access.Decode(rawConfig, access.StorageConfig.Validate) + if err != nil || config.Provider != "r2" || config.CeremonyID != protocol.Definition.CeremonyID { + t.Fatalf("retained recovery requires its exact R2 ceremony configuration: %v", err) + } + coordinator := reopenWorkflowV4LiveRole(t, root, "coordinator", protocol) + coordinator.profile.Credentials = credentialsPath + coordinator.profile.R2Parent = parentPath + coordinator.profile.R2Control = controlPath + releaseSigner := reopenWorkflowV4LiveRole(t, root, "release-signer", protocol) + defer coordinator.journal.close() + defer releaseSigner.journal.close() + objects := store.Client{PublicBaseURL: config.PublishedBaseURL} + + releaseSnapshot := syncWorkflowV4LiveRole(t, &releaseSigner, objects) + progress, err := workflowV4ReleaseSignerProgressFor(releaseSigner.profile.Work) + if err != nil { + t.Fatal(err) + } + if !progress.PackageReady { + if err := runWorkflowV4ReleaseSignerAction(workflowV4LiveUI("SIGN RELEASE PACKAGE\n"), releaseSnapshot, protocol, config, releaseSigner.profile, releaseSigner.signer, releaseSigner.identity, progress); err != nil { + t.Fatal(err) + } + progress, err = workflowV4ReleaseSignerProgressFor(releaseSigner.profile.Work) + if err != nil || !progress.PackageReady { + t.Fatalf("release package was not retained after signing: %+v %v", progress, err) + } + } + 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) + } + 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) + } + + freshRoot := t.TempDir() + freshTranscript := filepath.Join(freshRoot, "ceremony", "public") + freshTrust := filepath.Join(freshRoot, "trust") + for _, name := range []string{"ceremony.json", "ceremony.sig"} { + 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} + highWater, err := state.OpenWorkspaceHighWater(filepath.Join(freshRoot, "high-water"), protocol.Definition.CeremonyID) + if err != nil { + t.Fatal(err) + } + terminal, err := storagefirst.SyncV4(objects, freshInspector, highWater, protocol.Definition.CeremonyID, freshRoot) + if err != nil { + t.Fatal(err) + } + stateView, err := terminal.State() + if err != nil || stateView.Progress.FinalRelease == nil { + t.Fatalf("fresh verifier did not reconstruct the resumed final release: %+v %v", stateView.Progress, err) + } + t.Logf("resumed and freshly reconstructed live R2 V4 ceremony %s at signed update %d", protocol.Definition.CeremonyID, stateView.Sequence) +} + +func reopenWorkflowV4LiveRole(t *testing.T, root, role string, protocol transcript.DefinitionProtocol) workflowV4LiveRole { + t.Helper() + base := filepath.Join(root, role) + work, trust, keys := filepath.Join(base, "work"), filepath.Join(base, "trust"), filepath.Join(base, "keys") + var marker workflowV4Marker + if err := readWorkflowV4JSON(filepath.Join(work, ".relay-workspace-v4.json"), &marker); err != nil { + t.Fatal(err) + } + var identity setupIdentity + if err := readWorkflowV4JSON(filepath.Join(keys, "identity.json"), &identity); err != nil { + t.Fatal(err) + } + online, okOnline := marker.Binding.Runtimes["online"] + signing, okSigning := marker.Binding.Runtimes["signer"] + if !okOnline || !okSigning || marker.Binding.Work != work || marker.Binding.Role != role || marker.Binding.IdentityID != identity.ID { + t.Fatal("retained V4 role binding is incomplete or belongs to another workspace") + } + profile := guidedProfile{Schema: guidedSchema, Name: marker.Binding.Name, Role: role, Image: online.Image, Platform: online.Platform, Work: work, Trust: trust, Keys: keys} + signer := guidedProfile{Schema: guidedSchema, Name: offlineRoleAlias(profile.Name, role), Role: "decision-signer", Image: signing.Image, Platform: signing.Platform, Work: work, Trust: trust, Keys: keys} + keyName := "coordinator-public-key.hex" + if role == "coordinator" { + keyName = "setup-coordinator.hex" + } + rootPath := filepath.Join(work, "ceremony", "public") + driver := dockerDriver{image: profile.Image, platform: profile.Platform, ceremonyBinary: "/usr/local/bin/mpc-ceremony", root: rootPath, inspectionRoot: work, definition: filepath.Join(rootPath, "ceremony.json"), definitionSig: filepath.Join(rootPath, "ceremony.sig"), coordinatorKey: filepath.Join(trust, keyName), client: osDockerCommandClient{binary: workflowV4LiveDockerCLI(t)}} + journal, err := openWorkflowV4Journal(protocol, protocol.DefinitionRefs, marker.Binding) + if err != nil { + t.Fatal(err) + } + return workflowV4LiveRole{profile: profile, signer: signer, identity: identity, inspector: driver.inspector(), journal: journal} +} + +func newWorkflowV4LiveRole(t *testing.T, root, role, id, onlineImage, offlineImage, platform string) workflowV4LiveRole { + t.Helper() + base := filepath.Join(root, role) + work, trust, keys := filepath.Join(base, "work"), filepath.Join(base, "trust"), filepath.Join(base, "keys") + for _, dir := range []string{work, trust, keys} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + pub, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + fingerprint := sha256.Sum256(pub) + identity := setupIdentity{ID: id, DisplayName: "Live rehearsal " + role, KeyID: id + "-key", PublicKey: hex.EncodeToString(pub), Fingerprint: fmt.Sprintf("sha256:%x", fingerprint)} + if err := writeJSONNoReplace(filepath.Join(keys, "identity.json"), identity, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(keys, "signing.hex"), []byte(hex.EncodeToString(private.Seed())+"\n"), 0o600); err != nil { + t.Fatal(err) + } + name := "v4-live-" + role + profile := guidedProfile{Schema: guidedSchema, Name: name, Role: role, Image: onlineImage, Platform: platform, Work: work, Trust: trust, Keys: keys} + signer := guidedProfile{Schema: guidedSchema, Name: offlineRoleAlias(name, role), Role: "decision-signer", Image: offlineImage, Platform: platform, Work: work, Trust: trust, Keys: keys} + return workflowV4LiveRole{profile: profile, signer: signer, identity: identity} +} + +func workflowV4LiveSetupRunner(t *testing.T, image, platform string, draft coordinatorDraft) func([]string) error { + t.Helper() + var command []string + return func(args []string) error { + if len(args) > 1 && args[1] == "setup" { + for n, value := range args { + if value == "--" { + command = append([]string(nil), args[n+1:]...) + return nil + } + } + return errors.New("missing tool command") + } + if len(command) == 0 { + return errors.New("missing tool command") + } + argv, err := dockerRoleArgs(dockerRoleOptions{role: "coordinator", image: image, platform: platform, work: draft.Work, trust: draft.Trust, keys: draft.Keys}, command, os.Getuid(), os.Getgid()) + if err != nil { + return err + } + output, err := execDocker(argv) + if err != nil { + return fmt.Errorf("%w: %s", err, output) + } + return nil + } +} + +func execDocker(args []string) ([]byte, error) { + return exec.Command("docker", args...).CombinedOutput() +} + +func writeWorkflowV4LiveConfig(t *testing.T, work string, config access.StorageConfig) { + t.Helper() + path := filepath.Join(work, "ceremony", "config", "relay-storage.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := writeJSONNoReplace(path, config, 0o600); err != nil { + t.Fatal(err) + } +} + +func copyWorkflowV4LiveFile(t *testing.T, source, destination string) { + t.Helper() + raw, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(destination, raw, 0o600); err != nil { + t.Fatal(err) + } +} + +func copyWorkflowV4LiveBootstrap(t *testing.T, coordinator workflowV4LiveRole, role *workflowV4LiveRole, config access.StorageConfig) { + t.Helper() + for _, name := range []string{"ceremony.json", "ceremony.sig"} { + copyWorkflowV4LiveFile(t, filepath.Join(coordinator.profile.Work, "ceremony", "public", name), filepath.Join(role.profile.Work, "ceremony", "public", name)) + } + copyWorkflowV4LiveFile(t, filepath.Join(coordinator.profile.Trust, "setup-coordinator.hex"), filepath.Join(role.profile.Trust, "coordinator-public-key.hex")) + copyWorkflowV4LiveFile(t, filepath.Join(coordinator.profile.Trust, "setup-coordinator.hex"), filepath.Join(role.profile.Trust, "setup-coordinator.hex")) + writeWorkflowV4LiveConfig(t, role.profile.Work, config) +} + +func prepareWorkflowV4LiveRole(t *testing.T, role *workflowV4LiveRole, coordinator workflowV4LiveRole, config access.StorageConfig, protocol transcript.DefinitionProtocol, contributorImage, platform string) { + t.Helper() + if role.profile.Work != coordinator.profile.Work { + copyWorkflowV4LiveBootstrap(t, coordinator, role, config) + } + root := filepath.Join(role.profile.Work, "ceremony", "public") + keyName := "coordinator-public-key.hex" + if role.profile.Role == "coordinator" { + keyName = "setup-coordinator.hex" + } + dockerCLI := workflowV4LiveDockerCLI(t) + driver := dockerDriver{image: role.profile.Image, platform: platform, ceremonyBinary: "/usr/local/bin/mpc-ceremony", root: root, inspectionRoot: role.profile.Work, definition: filepath.Join(root, "ceremony.json"), definitionSig: filepath.Join(root, "ceremony.sig"), coordinatorKey: filepath.Join(role.profile.Trust, keyName), client: osDockerCommandClient{binary: dockerCLI}} + role.inspector = driver.inspector() + if role.profile.Role == "participant" { + environment := guidedEnvironment{"linux", strings.TrimPrefix(platform, "linux/"), "operating-system-csprng", true, true, true, true, true, true} + environmentPath := filepath.Join(role.profile.Work, "environment.json") + canonical, err := json.Marshal(environment) + if err != nil { + t.Fatal(err) + } + if err := writePublicTextOnce(environmentPath, string(canonical)); err != nil { + t.Fatal(err) + } + participant := access.RoleConfig{Schema: access.RoleConfigSchema, Role: "participant", IdentityID: role.identity.ID, Phase: "phase1", CeremonyID: protocol.Definition.CeremonyID, CeremonyHome: filepath.Join(role.profile.Work, "ceremony"), Root: root, Ceremony: filepath.Join(root, "ceremony.json"), CeremonySignature: filepath.Join(root, "ceremony.sig"), CoordinatorKey: filepath.Join(role.profile.Trust, "coordinator-public-key.hex"), CeremonyBinary: "/usr/local/bin/mpc-ceremony", SigningKey: filepath.Join(role.profile.Keys, "signing.hex"), Environment: environmentPath, RunRoot: filepath.Join(role.profile.Work, "ceremony", "run"), StorageConfig: filepath.Join(role.profile.Work, "ceremony", "config", "relay-storage.json"), PublishedBaseURL: config.PublishedBaseURL, PublishedBucket: config.PublishedBucket, ExecutionMode: dockerExecutionMode, DockerImage: contributorImage, DockerPlatform: platform, DockerCLI: dockerCLI} + if err := participant.Validate(); err != nil { + t.Fatal(err) + } + role.participant = &participant + role.profile.Image = contributorImage + } + binding, err := workflowV4ProfileBinding(role.profile, role.signer, protocol, role.identity, role.participant) + if err != nil { + t.Fatal(err) + } + role.journal, err = openWorkflowV4Journal(protocol, protocol.DefinitionRefs, binding) + if err != nil { + t.Fatal(err) + } +} + +func prepareWorkflowV4LiveEnrollment(t *testing.T, role workflowV4LiveRole) { + t.Helper() + if err := os.WriteFile(filepath.Join(role.profile.Work, "enrollment-disclosure.txt"), []byte("Automated same-host live rehearsal; no operator independence claim.\n"), 0o600); err != nil { + t.Fatal(err) + } + roleName := role.profile.Role + prepare := []string{"mpc-ceremony", "ops", "prepare-enrollment", "--ceremony", "/work/ceremony/public/ceremony.json", "--ceremony-signature", "/work/ceremony/public/ceremony.sig", "--coordinator-public-key-file", "/trust/coordinator-public-key.hex", "--identity", "/keys/identity.json", "--role", roleName, "--role-index", "1", "--disclosure", "/work/enrollment-disclosure.txt", "--enrolled-at", time.Now().UTC().Format(time.RFC3339Nano), "--out-dir", "/work/my-enrollment"} + if err := runWorkflowV4ProfileCommand(role.signer, prepare, false); err != nil { + t.Fatal(err) + } + record := filepath.Join(role.profile.Work, "my-enrollment", "canonical.json") + raw, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + sign := []string{"mpc-ceremony", "ops", "sign", "--record-type", "enrollment", "--record", "/work/my-enrollment/canonical.json", "--ceremony", "/work/ceremony/public/ceremony.json", "--ceremony-signature", "/work/ceremony/public/ceremony.sig", "--coordinator-public-key-file", "/trust/coordinator-public-key.hex", "--signing-key", "/keys/signing.hex", "--reviewed", "--reviewed-sha256", fmt.Sprintf("%x", sha256.Sum256(raw)), "--out", "/work/my-enrollment/enrollment.sig"} + if err := runWorkflowV4ProfileCommand(role.signer, sign, false); err != nil { + t.Fatal(err) + } +} + +func syncWorkflowV4LiveRole(t *testing.T, role *workflowV4LiveRole, objects store.Client) storagefirst.SnapshotV4 { + t.Helper() + snapshot, err := role.journal.syncV4(objects, role.inspector, workflowV4LiveDockerCLI(t)) + if err != nil { + t.Fatal(err) + } + return snapshot +} + +func workflowV4LiveDockerCLI(t *testing.T) string { + t.Helper() + path, err := exec.LookPath("docker") + if err != nil { + t.Fatal(err) + } + path, err = filepath.Abs(path) + if err != nil { + t.Fatal(err) + } + return filepath.Clean(path) +} + +func workflowV4LiveUI(input string) *coordinatorWizard { + return &coordinatorWizard{input: bufio.NewReader(strings.NewReader(input)), output: new(bytes.Buffer)} +} + +func runWorkflowV4LiveTurn(t *testing.T, objects store.Client, protocol transcript.DefinitionProtocol, config access.StorageConfig, coordinator, participant *workflowV4LiveRole, phase string) { + t.Helper() + snapshot := syncWorkflowV4LiveRole(t, coordinator, objects) + view, err := snapshot.TurnV4(protocol, phase, "") + if err != nil { + t.Fatal(err) + } + progress, err := workflowV4CoordinatorProgressFor(snapshot, protocol, view, coordinator.journal.state.Marker.Binding, config, coordinator.inspector, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + recommendation, err := snapshot.RecommendTurnV4(protocol, phase, storagefirst.Coordinator, "", progress.Local, "", time.Now().UTC()) + if err != nil || (recommendation.Action != "allocate-candidate-attempt" && recommendation.Action != "allocate-replacement-attempt") { + t.Fatalf("expected allocation, got %+v %v", recommendation, err) + } + if err := runWorkflowV4CoordinatorAction(workflowV4LiveUI("ALLOCATE TURN\n"), snapshot, protocol, config, coordinator.profile, coordinator.signer, coordinator.inspector, recommendation, view, progress); err != nil { + t.Fatal(err) + } + snapshot = syncWorkflowV4LiveRole(t, coordinator, objects) + view, err = snapshot.TurnV4(protocol, phase, "") + if err != nil { + t.Fatal(err) + } + progress, err = workflowV4CoordinatorProgressFor(snapshot, protocol, view, coordinator.journal.state.Marker.Binding, config, coordinator.inspector, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + recommendation, err = snapshot.RecommendTurnV4(protocol, phase, storagefirst.Coordinator, "", progress.Local, "", time.Now().UTC()) + if err != nil || recommendation.Action != "issue-candidate-grant" { + t.Fatalf("expected grant, got %+v %v", recommendation, err) + } + if err := runWorkflowV4CoordinatorAction(workflowV4LiveUI("CREATE GRANT\n"), snapshot, protocol, config, coordinator.profile, coordinator.signer, coordinator.inspector, recommendation, view, progress); err != nil { + t.Fatal(err) + } + grantPath := progress.GrantPath + + participantSnapshot := syncWorkflowV4LiveRole(t, participant, objects) + participantView, err := participantSnapshot.TurnV4(protocol, phase, participant.identity.ID) + if err != nil { + t.Fatal(err) + } + dockerCLI := workflowV4LiveDockerCLI(t) + participantProgress, err := participant.journal.participantProgressV4(participantView.Scope, dockerCLI) + if err != nil { + t.Fatal(err) + } + if err := runWorkflowV4ParticipantAction(workflowV4LiveUI("CONTRIBUTE\n"), participant.journal, participantSnapshot, protocol, *participant.participant, config, participant.inspector, dockerCLI, participantView, participantProgress); err != nil { + t.Fatal(err) + } + participantProgress, err = participant.journal.participantProgressV4(participantView.Scope, dockerCLI) + if err != nil { + t.Fatal(err) + } + if err := runWorkflowV4ParticipantAction(workflowV4LiveUI("CLEANUP PRECAUTIONS CONFIRMED\n"), participant.journal, participantSnapshot, protocol, *participant.participant, config, participant.inspector, dockerCLI, participantView, participantProgress); err != nil { + t.Fatal(err) + } + participantProgress, err = participant.journal.participantProgressV4(participantView.Scope, dockerCLI) + if err != nil { + t.Fatal(err) + } + if err := runWorkflowV4ParticipantAction(workflowV4LiveUI(grantPath+"\nUPLOAD CANDIDATE\n"), participant.journal, participantSnapshot, protocol, *participant.participant, config, participant.inspector, dockerCLI, participantView, participantProgress); err != nil { + t.Fatal(err) + } + + progress, err = workflowV4CoordinatorProgressFor(snapshot, protocol, view, coordinator.journal.state.Marker.Binding, config, coordinator.inspector, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + recommendation, err = snapshot.RecommendTurnV4(protocol, phase, storagefirst.Coordinator, "", progress.Local, "", time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if err := runWorkflowV4CoordinatorAction(workflowV4LiveUI("CHECK INBOX\n"), snapshot, protocol, config, coordinator.profile, coordinator.signer, coordinator.inspector, recommendation, view, progress); err != nil { + t.Fatal(err) + } + progress, err = workflowV4CoordinatorProgressFor(snapshot, protocol, view, coordinator.journal.state.Marker.Binding, config, coordinator.inspector, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + recommendation, err = snapshot.RecommendTurnV4(protocol, phase, storagefirst.Coordinator, "", progress.Local, progress.Local.CandidateReceivedAttemptID, time.Now().UTC()) + if err != nil || recommendation.Action != "verify-and-accept-candidate" { + t.Fatalf("expected acceptance, got %+v %v", recommendation, err) + } + if err := runWorkflowV4CoordinatorAction(workflowV4LiveUI("VERIFY AND ACCEPT\n"), snapshot, protocol, config, coordinator.profile, coordinator.signer, coordinator.inspector, recommendation, view, progress); err != nil { + t.Fatal(err) + } +} + +func runWorkflowV4LiveLifecycle(t *testing.T, objects store.Client, protocol transcript.DefinitionProtocol, coordinator *workflowV4LiveRole, expected, confirmation string) { + t.Helper() + snapshot := syncWorkflowV4LiveRole(t, coordinator, objects) + stateView, err := snapshot.State() + if err != nil { + t.Fatal(err) + } + commitments, err := snapshot.Commitments() + if err != nil { + t.Fatal(err) + } + action, _, err := workflowV4CoordinatorLifecycleAction(stateView, commitments, protocol) + if err != nil || action != expected { + t.Fatalf("expected lifecycle %s, got %s: %v", expected, action, err) + } + if err := runWorkflowV4CoordinatorLifecycle(workflowV4LiveUI(confirmation), action, snapshot, protocol, coordinator.profile, coordinator.signer, coordinator.inspector); err != nil { + t.Fatal(err) + } +} + +func runWorkflowV4LiveBeacon(t *testing.T, objects store.Client, protocol transcript.DefinitionProtocol, coordinator *workflowV4LiveRole, expected, confirmation string) { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for { + snapshot := syncWorkflowV4LiveRole(t, coordinator, objects) + stateView, err := snapshot.State() + if err != nil { + t.Fatal(err) + } + commitments, err := snapshot.Commitments() + if err != nil { + t.Fatal(err) + } + action, _, err := workflowV4CoordinatorLifecycleAction(stateView, commitments, protocol) + if err != nil || action != expected { + t.Fatalf("expected beacon lifecycle %s, got %s: %v", expected, action, err) + } + err = runWorkflowV4CoordinatorLifecycle(workflowV4LiveUI(confirmation), action, snapshot, protocol, coordinator.profile, coordinator.signer, coordinator.inspector) + if err == nil { + return + } + if !strings.Contains(err.Error(), "not public yet") || time.Now().After(deadline) { + t.Fatal(err) + } + time.Sleep(time.Second) + } +} diff --git a/cmd/relay/workflow_v4_guide.go b/cmd/relay/workflow_v4_guide.go new file mode 100644 index 0000000..294454c --- /dev/null +++ b/cmd/relay/workflow_v4_guide.go @@ -0,0 +1,443 @@ +package main + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +// A hint selects the verifier, never authenticates the definition. An existing +// V4 workspace cannot fall back to legacy progress after losing its definition. +func workflowV4RouteHint(p guidedProfile) (bool, error) { + if p.Work == "" { + return false, nil + } + for _, path := range []string{filepath.Join(p.Work, ".relay-workspace-v4.json"), filepath.Join(p.Work, "workflow-v4", "state.json")} { + if _, err := os.Lstat(path); err == nil { + return true, nil + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + } + raw, err := readTesseraRegularFile(filepath.Join(p.Work, "ceremony", "public", "ceremony.json"), 16<<20, false) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if err := rejectCommitJournalDuplicateFields(raw); err != nil { + return false, err + } + var hint struct { + Schema string `json:"schema"` + } + if err := json.Unmarshal(raw, &hint); err != nil { + return false, err + } + switch hint.Schema { + case "proof-tool-mpc-ceremony-definition-v4": + return true, nil + case "proof-tool-mpc-ceremony-definition-v1", "proof-tool-mpc-ceremony-definition-v2", "proof-tool-mpc-ceremony-definition-v3": + return false, nil + default: + return false, errors.New("unsupported ceremony format; preserve this workspace and use its matching release") + } +} + +func runWorkflowV4Guide(p guidedProfile, settingsRoot string) error { + if p.Role != "coordinator" && p.Role != "participant" && p.Role != "release-signer" && p.Role != "auditor" { + return errors.New("this V4 role journey is not connected yet; no legacy actions were opened") + } + var participant *access.RoleConfig + storagePath := filepath.Join(p.Work, "ceremony", "config", "relay-storage.json") + key := filepath.Join(p.Trust, "setup-coordinator.hex") + if p.Role == "release-signer" || p.Role == "auditor" { + key = filepath.Join(p.Trust, "coordinator-public-key.hex") + } + dockerCLI := "docker" + if p.Role == "participant" { + c, err := loadRoleConfig(p.Config, "participant") + if err != nil { + return err + } + participant, storagePath, key, dockerCLI = &c, c.StorageConfig, c.CoordinatorKey, c.DockerCLI + } + cli, err := exec.LookPath(dockerCLI) + if err != nil { + return err + } + cli, err = filepath.Abs(cli) + if err != nil { + return err + } + signerDir, err := guidedDirectory(settingsRoot, offlineRoleAlias(p.Name, p.Role), "decision-signer") + if err != nil { + return err + } + signer, err := readGuidedProfile(filepath.Join(signerDir, "profile.json"), offlineRoleAlias(p.Name, p.Role), "decision-signer") + if err != nil { + return fmt.Errorf("prepare this role's network-disabled signing image in onboarding: %w", err) + } + var identity setupIdentity + if err := setupReadJSON(filepath.Join(p.Keys, "identity.json"), &identity); err != nil { + return err + } + root := filepath.Join(p.Work, "ceremony", "public") + // Validate runtime and confined mounts before invoking any container. + runtime := workflowV4Runtime{Image: p.Image, Platform: p.Platform, Mounts: map[string]string{"/work": p.Work, "/trust": p.Trust, "/keys": p.Keys}} + if err := validateWorkflowV4Runtime(runtime, p.Work); err != nil { + return err + } + if _, err := pathWithin(p.Trust, key, "/trust"); err != nil { + return err + } + if err := prepareGuidedImage(p.Image, p.Platform, cli, false); err != nil { + return err + } + d := dockerDriver{image: p.Image, platform: p.Platform, ceremonyBinary: "/usr/local/bin/mpc-ceremony", root: root, inspectionRoot: p.Work, definition: filepath.Join(root, "ceremony.json"), definitionSig: filepath.Join(root, "ceremony.sig"), coordinatorKey: key, client: osDockerCommandClient{binary: cli}} + if err := d.authenticateDaemon(); err != nil { + return err + } + inspector := d.inspector() + protocol, err := inspector.DefinitionProtocol() + if err != nil { + return fmt.Errorf("authenticate V4 ceremony; legacy fallback is disabled: %w", err) + } + binding, err := workflowV4ProfileBinding(p, signer, protocol, identity, participant) + if err != nil { + return err + } + j, err := openWorkflowV4Journal(protocol, protocol.DefinitionRefs, binding) + if err != nil { + return err + } + defer j.close() + if _, err := pathWithin(p.Work, storagePath, "/work"); err != nil { + return err + } + raw, err := readTesseraRegularFile(storagePath, 1<<20, false) + if err != nil { + return fmt.Errorf("import your coordinator's public storage settings: %w", err) + } + config, err := access.Decode(raw, access.StorageConfig.Validate) + if err != nil { + return err + } + if config.CeremonyID != binding.CeremonyID { + return errors.New("storage settings belong to another ceremony") + } + if participant != nil && (config.PublishedBaseURL != participant.PublishedBaseURL || config.PublishedBucket != participant.PublishedBucket) { + return errors.New("public storage settings differ from your saved participant profile") + } + if err := validateStorageFirstOrigin("public storage address", config.PublishedBaseURL); err != nil { + return err + } + objects := store.Client{PublicBaseURL: config.PublishedBaseURL} + ui := coordinatorWizard{input: bufio.NewReader(os.Stdin), output: os.Stdout} + for { + var snapshot storagefirst.SnapshotV4 + var turn storagefirst.TurnViewV4 + var progress workflowV4ParticipantProgress + var coordinatorProgress workflowV4CoordinatorProgress + var releaseProgress workflowV4ReleaseSignerProgress + var enrollmentExpected *transcript.ExpectedEnrollment + enrollmentAction := false + var recommendation storagefirst.TurnRecommendationV4 + lifecycleAction := "" + actionLabel := "" + pending, err := j.pending() + if err != nil { + return err + } + snapshot, err = j.syncV4(objects, inspector, cli) + if err != nil { + ui.message(toneError, "Storage synchronization failed: %v\nNo new ceremony action is authorized. Retained work is unchanged.\n", err) + printWorkflowV4Pending(ui.output, pending) + } else { + c, err := snapshot.State() + if err != nil { + return err + } + if c.Definition != binding.Definition { + return errors.New("backend definition differs from this role's authenticated definition") + } + if p.Role == "coordinator" { + enrollmentExpected, err = workflowV4NextRequiredEnrollment(snapshot, protocol) + if err != nil { + return err + } + if enrollmentExpected != nil { + enrollmentAction = true + coordinatorProgress, err = workflowV4CoordinatorEnrollmentProgressFor(snapshot, protocol, *enrollmentExpected, binding, config, time.Now().UTC()) + if err != nil { + return err + } + switch { + case enrollmentExpected.Role == "coordinator": + actionLabel = "Verify and record your coordinator enrollment" + case coordinatorProgress.EnrollmentGrant == nil: + actionLabel = fmt.Sprintf("Create the %s enrollment upload grant", enrollmentExpected.Role) + case !regularPreparationFile(filepath.Join(coordinatorProgress.EnrollmentDir, "enrollment.json")): + actionLabel = fmt.Sprintf("Check the private inbox for the %s enrollment", enrollmentExpected.Role) + default: + actionLabel = fmt.Sprintf("Verify and record the %s enrollment", enrollmentExpected.Role) + } + } + } else { + committed, err := workflowV4EnrollmentCommitted(snapshot, identity.ID) + if err != nil { + return err + } + if !committed { + enrollmentAction = true + actionLabel = "Upload your signed public enrollment" + } + } + phase, who := "phase1", "" + if c.Progress.Phase2 != nil { + phase = "phase2" + } + if p.Role == "participant" { + who = identity.ID + phase = participant.Phase + } + scheduled := false + if p.Role != "release-signer" && p.Role != "auditor" { + scheduled, err = workflowV4ScheduledInPhase(protocol, phase, who) + if err != nil { + return err + } + } + turn = storagefirst.TurnViewV4{Stage: "not-scheduled-in-this-phase"} + if enrollmentAction { + turn.Stage = "required-enrollment-not-recorded" + } else if p.Role == "release-signer" { + turn.Stage = "waiting-for-coordinator-review" + if c.Progress.FinalRelease != nil { + turn.Stage = "release-recorded" + } else if c.Progress.ReleaseReview != nil { + turn.Stage = "release-review-ready" + releaseProgress, err = workflowV4ReleaseSignerProgressFor(p.Work) + if err != nil { + ui.message(toneError, "Retained release-signer work could not be verified: %v\nNo operation was repeated.\n", err) + } else if releaseProgress.PackageReady { + actionLabel = "Upload the exact signed release package using the private release grant" + } else { + actionLabel = "Review and sign the exact coordinator-reviewed release package" + } + } + } else if p.Role == "auditor" { + turn.Stage = "waiting-for-final-candidate" + } else if scheduled || c.Progress.Terminal != nil { + // For an unscheduled participant, terminal status is global; + // never present another participant's active turn as theirs. + if !scheduled { + who = "" + } + turn, err = snapshot.TurnV4(protocol, phase, who) + if err != nil { + return err + } + } + if enrollmentAction { + // Enrollment is a ceremony-wide prerequisite; do not offer later + // participant, audit, or release work until it is recorded. + } else if p.Role == "participant" && turn.Scope.ParticipantID != "" { + progress, err = j.participantProgressV4(turn.Scope, cli) + if err != nil { + ui.message(toneError, "Retained participant work could not be verified: %v\nNo operation was repeated.\n", err) + } else { + recommendation, err = workflowV4ParticipantRecommendation(snapshot, protocol, *participant, progress, time.Now().UTC()) + if err != nil { + return err + } + actionLabel = workflowV4ParticipantActionLabel(recommendation, pending) + } + } else if p.Role == "coordinator" && turn.Scope.ParticipantID != "" { + coordinatorProgress, err = workflowV4CoordinatorProgressFor(snapshot, protocol, turn, binding, config, inspector, time.Now().UTC()) + if err != nil { + ui.message(toneError, "Retained coordinator work could not be verified: %v\nNo operation was repeated.\n", err) + } else if coordinatorProgress.CandidateTransportError != "" { + ui.message(toneError, "The retained candidate is not the exact package previously transport-checked: %s\nRelay will preserve it for investigation and fetch the same authenticated attempt into a fresh private folder.\n", coordinatorProgress.CandidateTransportError) + if recovery, ok := workflowV4CoordinatorTransportRecoveryRecommendation(turn, coordinatorProgress); ok { + recommendation = recovery + actionLabel = workflowV4CoordinatorActionLabel(recommendation, coordinatorProgress) + } + } else if candidateRecommendation, reviewCandidate := workflowV4CoordinatorCandidateRecommendation(turn, coordinatorProgress); reviewCandidate { + // The candidate directory exists only after the coordinator's + // atomic transport fetch checked the fixed five-file package. + // Do not silently discard it or automatically reject it: expose + // the one signed recovery transition for an explicit review. + ui.message(toneError, "Proof-tool could not verify the transport-checked candidate: %s\nReview the retained files before deciding whether to reject this attempt.\n", coordinatorProgress.CandidateInspectionError) + recommendation = candidateRecommendation + actionLabel = workflowV4CoordinatorActionLabel(recommendation, coordinatorProgress) + } else { + observed := "" + if coordinatorProgress.Local.CandidateReceivedAttemptID != "" { + observed = coordinatorProgress.Local.CandidateReceivedAttemptID + } + recommendation, err = snapshot.RecommendTurnV4(protocol, phase, storagefirst.Coordinator, "", coordinatorProgress.Local, observed, time.Now().UTC()) + if err != nil { + return err + } + actionLabel = workflowV4CoordinatorActionLabel(recommendation, coordinatorProgress) + } + } else if p.Role == "coordinator" { + commitments, commitmentErr := snapshot.Commitments() + if commitmentErr != nil { + return commitmentErr + } + lifecycleAction, actionLabel, err = workflowV4CoordinatorLifecycleAction(c, commitments, protocol) + if err != nil { + return err + } + } + printWorkflowV4Status(ui.output, p.Role, c, turn, pending, time.Now().UTC()) + if recommendation.Reason != "" { + fmt.Fprintf(ui.output, "Next: %s\n", recommendation.Reason) + } + if actionLabel != "" { + fmt.Fprintf(ui.output, "1) %s\n", actionLabel) + } + } + fmt.Fprintln(ui.output, "[R] Refresh from storage\n[Q] Save and exit") + answer, err := ui.ask("Choose", "Q") + if err != nil { + return err + } + switch strings.ToUpper(strings.TrimSpace(answer)) { + case "1": + if actionLabel == "" { + fmt.Fprintln(ui.output, "No role action is available for the authenticated state.") + continue + } + if enrollmentAction { + var actionErr error + if p.Role == "coordinator" { + if enrollmentExpected == nil { + actionErr = errors.New("no missing signed enrollment was selected") + } else { + actionErr = runWorkflowV4CoordinatorExpectedEnrollment(&ui, snapshot, protocol, config, p, signer, inspector, *enrollmentExpected, coordinatorProgress) + } + } else { + actionErr = runWorkflowV4OwnEnrollmentUpload(&ui, snapshot, protocol, p, config, inspector, identity) + } + if actionErr != nil { + ui.message(toneError, "Enrollment action stopped: %v\nSigned files and verified downloads were retained. No action is automatically repeated.\n", actionErr) + continue + } + } else if p.Role == "participant" && participant != nil { + if err := runWorkflowV4ParticipantAction(&ui, j, snapshot, protocol, *participant, config, inspector, cli, turn, progress); err != nil { + ui.message(toneError, "Participant action stopped: %v\nSaved state and verified public files were retained. No action is automatically repeated.\n", err) + continue + } + } else if p.Role == "coordinator" { + var actionErr error + if lifecycleAction != "" { + actionErr = runWorkflowV4CoordinatorLifecycle(&ui, lifecycleAction, snapshot, protocol, p, signer, inspector) + } else { + actionErr = runWorkflowV4CoordinatorAction(&ui, snapshot, protocol, config, p, signer, inspector, recommendation, turn, coordinatorProgress) + } + if actionErr != nil { + ui.message(toneError, "Coordinator action stopped: %v\nSigned files and verified downloads were retained. No action is automatically repeated.\n", actionErr) + continue + } + } else if p.Role == "release-signer" { + if err := runWorkflowV4ReleaseSignerAction(&ui, snapshot, protocol, config, p, signer, identity, releaseProgress); err != nil { + ui.message(toneError, "Release-signer action stopped: %v\nVerified downloads and any complete signed package were retained. No signing or upload is automatically repeated.\n", err) + continue + } + } + fmt.Fprintln(ui.output, "Action finished locally. Relay will refresh signed storage state before recommending anything else.") + case "Q": + return nil + case "R": + default: + fmt.Fprintln(ui.output, "Choose a displayed action, R or Q.") + } + } +} + +func workflowV4CoordinatorCandidateRecommendation(turn storagefirst.TurnViewV4, progress workflowV4CoordinatorProgress) (storagefirst.TurnRecommendationV4, bool) { + if turn.CandidateAttempt == nil || progress.CandidateDir == "" || progress.CandidateTransportError != "" || progress.CandidateInspectionError == "" { + return storagefirst.TurnRecommendationV4{}, false + } + return storagefirst.TurnRecommendationV4{ + Action: "review-and-reject-candidate", + Ready: true, + Reason: "the transport-checked candidate failed proof-tool verification; review it and explicitly reject it before a fresh replacement contribution.", + Scope: turn.Scope, + AttemptID: turn.CandidateAttempt.AttemptID, + }, true +} + +func workflowV4CoordinatorTransportRecoveryRecommendation(turn storagefirst.TurnViewV4, progress workflowV4CoordinatorProgress) (storagefirst.TurnRecommendationV4, bool) { + if turn.CandidateAttempt == nil || progress.CandidateDir == "" || progress.CandidateTransportError == "" { + return storagefirst.TurnRecommendationV4{}, false + } + return storagefirst.TurnRecommendationV4{ + Action: "recover-candidate-download", + Ready: true, + Reason: "the retained candidate package needs transport recovery before it can be inspected or decided.", + Scope: turn.Scope, + AttemptID: turn.CandidateAttempt.AttemptID, + }, true +} + +func printWorkflowV4Status(out io.Writer, role string, c transcript.CheckpointStateV4, turn storagefirst.TurnViewV4, pending *workflowV4Operation, checked time.Time) { + fmt.Fprintf(out, "\nRELAY | %s | STORAGE-FIRST\n------------------------------------------------------------\nChecked storage at %s; signed update %d.\nPhase 1: %d accepted contributions.\n", strings.ToUpper(role), checked.Format(time.RFC3339), c.Sequence, c.Progress.Phase1.AcceptedCount) + if c.Progress.Phase2 != nil { + fmt.Fprintf(out, "Phase 2: %d accepted contributions.\n", c.Progress.Phase2.AcceptedCount) + } + fmt.Fprintf(out, "Backend turn status: %s\n", turn.Stage) + if c.Progress.FinalRelease != nil { + fmt.Fprintln(out, "Final release recorded; public publication is a separate check.") + } else if c.Progress.ReleaseReview != nil { + fmt.Fprintln(out, "Coordinator evidence is frozen for review; waiting for the release signer.") + } else if c.Progress.FinalCandidate != nil { + fmt.Fprintln(out, "Final files prepared for review; release is not yet recorded.") + } else if c.Progress.Phase1Closure != nil && c.Progress.Phase2 == nil { + fmt.Fprintln(out, "Phase 1 is closed; Phase 2 has not started.") + } + if turn.Scope.ParticipantID != "" { + fmt.Fprintf(out, "%s turn %d: %s\n", turn.Scope.Phase, turn.Scope.Index, turn.Scope.ParticipantID) + } + printWorkflowV4Pending(out, pending) + fmt.Fprintln(out, "This is the newest update returned by the configured storage service, not proof that no newer state exists elsewhere.") + fmt.Fprintln(out, "Signatures and recorded progress checked; contribution mathematics were not replayed by this refresh.") +} + +func printWorkflowV4Pending(out io.Writer, pending *workflowV4Operation) { + if pending != nil { + fmt.Fprintf(out, "Retained operation needs inspection: %s (%s). It will not be repeated automatically.\n", pending.Plan.Kind, pending.Status) + } +} + +func workflowV4ScheduledInPhase(protocol transcript.DefinitionProtocol, phase, identity string) (bool, error) { + schedule, err := protocol.Definition.Schedule(phase) + if err != nil { + return false, err + } + if identity == "" { + return true, nil + } + for _, id := range schedule { + if id == identity { + return true, nil + } + } + return false, nil +} diff --git a/cmd/relay/workflow_v4_guide_test.go b/cmd/relay/workflow_v4_guide_test.go new file mode 100644 index 0000000..5a8d330 --- /dev/null +++ b/cmd/relay/workflow_v4_guide_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +func TestWorkflowV4RoutingIsIrrevocable(t *testing.T) { + for _, version := range []int{1, 2, 3, 4} { + p := guidedProfile{Work: t.TempDir()} + path := filepath.Join(p.Work, "ceremony", "public", "ceremony.json") + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(fmt.Sprintf(`{"schema":"proof-tool-mpc-ceremony-definition-v%d"}`, version)), 0600); err != nil { + t.Fatal(err) + } + got, err := workflowV4RouteHint(p) + if err != nil || got != (version == 4) { + t.Fatalf("version %d: %v %v", version, got, err) + } + // A retained V4 marker always wins, including a damaged marker: the + // V4 opener will reject it, not create a second legacy workflow. + if err := os.WriteFile(filepath.Join(p.Work, ".relay-workspace-v4.json"), []byte("damaged"), 0600); err != nil { + t.Fatal(err) + } + if got, err := workflowV4RouteHint(p); err != nil || !got { + t.Fatal("downgraded existing V4 workspace", err) + } + } + for _, data := range []string{`{`, `{"schema":"future-format"}`, `{"schema":"proof-tool-mpc-ceremony-definition-v4","schema":"proof-tool-mpc-ceremony-definition-v3"}`} { + p := guidedProfile{Work: t.TempDir()} + path := filepath.Join(p.Work, "ceremony", "public", "ceremony.json") + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0600); err != nil { + t.Fatal(err) + } + if _, err := workflowV4RouteHint(p); err == nil { + t.Fatal("ambiguous schema allowed legacy route") + } + } +} + +func TestNormalGuideV4FailureDoesNotTouchLegacyProgress(t *testing.T) { + settings := t.TempDir() + p := guidedProfile{Schema: guidedSchema, Name: "test", Role: "coordinator", Work: t.TempDir(), Trust: t.TempDir(), Keys: t.TempDir()} + dir, err := guidedDirectory(settings, p.Name, p.Role) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "workflow"), 0700); err != nil { + t.Fatal(err) + } + legacy := filepath.Join(dir, "workflow", "state.json") + if err := os.WriteFile(legacy, []byte("deliberately invalid legacy state"), 0600); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "profile.json"), raw, 0600); err != nil { + t.Fatal(err) + } + // The retained marker selects V4 even with no definition. A missing + // signing profile must stop before legacy migration or Docker execution. + if err := os.WriteFile(filepath.Join(p.Work, ".relay-workspace-v4.json"), []byte("marker"), 0600); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("must not execute"), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + err = runRoleFlow([]string{p.Name, "--role", p.Role, "--settings-root", settings}) + if err == nil || !strings.Contains(err.Error(), "network-disabled signing image") { + t.Fatalf("wrong route: %v", err) + } + after, err := os.ReadFile(legacy) + if err != nil || string(after) != "deliberately invalid legacy state" { + t.Fatal("legacy state changed", err) + } + if _, err := os.Stat(filepath.Join(p.Work, "workflow-v4", "state.json")); !os.IsNotExist(err) { + t.Fatal("failed authentication created V4 journal", err) + } +} + +func TestWorkflowV4StatusDoesNotInventLocalReadiness(t *testing.T) { + var out bytes.Buffer + c := transcript.CheckpointStateV4{Sequence: 8} + c.Progress.Phase2 = &transcript.CheckpointPhaseState{Phase: "phase2", AcceptedCount: 1} + turn := storagefirst.TurnViewV4{Stage: storagefirst.TurnCandidateV4, Scope: transcript.ContributionScopeV4{Phase: "phase2", Index: 2, ParticipantID: "participant-test"}} + pending := &workflowV4Operation{Plan: workflowV4OperationPlan{Kind: "contribute"}, Status: "running"} + printWorkflowV4Status(&out, "participant", c, turn, pending, time.Date(2026, 9, 16, 0, 0, 0, 0, time.UTC)) + for _, want := range []string{"Phase 2: 1", "phase2 turn 2", "Retained operation needs inspection", "not be repeated automatically", "mathematics were not replayed"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("missing %q: %s", want, out.String()) + } + } + if strings.Contains(out.String(), "Ready") || strings.Contains(out.String(), "NEXT REQUIRED ACTION") { + t.Fatal("unimplemented action advertised ready") + } +} + +func TestWorkflowV4PhaseSpecificParticipants(t *testing.T) { + p := transcript.DefinitionProtocol{} + p.Definition.Phase1Participants = []string{"phase1-only"} + p.Definition.Phase2Participants = []string{"phase2-only"} + for _, tc := range []struct { + phase, identity string + want bool + }{ + {"phase1", "phase1-only", true}, {"phase1", "phase2-only", false}, + {"phase2", "phase1-only", false}, {"phase2", "phase2-only", true}, + {"phase1", "", true}, + } { + got, err := workflowV4ScheduledInPhase(p, tc.phase, tc.identity) + if err != nil || got != tc.want { + t.Fatalf("%+v: %v %v", tc, got, err) + } + } + var output bytes.Buffer + printWorkflowV4Pending(&output, &workflowV4Operation{Plan: workflowV4OperationPlan{Kind: "contribute"}, Status: "running"}) + if !strings.Contains(output.String(), "contribute (running)") { + t.Fatal("pending view requires backend snapshot") + } +} diff --git a/cmd/relay/workflow_v4_inspector.go b/cmd/relay/workflow_v4_inspector.go new file mode 100644 index 0000000..4206c26 --- /dev/null +++ b/cmd/relay/workflow_v4_inspector.go @@ -0,0 +1,120 @@ +package main + +import ( + "errors" + "fmt" + "path/filepath" + "reflect" + "strings" + + "github.com/zksecurity/relay/internal/transcript" +) + +// workflowV4CandidateInspector uses the original contributor image for retained +// output inspection. Its mounts are public, read-only and limited to this +// operation's retained transcript, candidate, scope and definition trust files. +func workflowV4CandidateInspector(p workflowV4OperationPlan, b workflowV4Binding, scopeFile string, client dockerCommandClient, daemon dockerDaemonFacts) (transcript.Inspector, error) { + var zero transcript.Inspector + if client == nil { + return zero, errors.New("Docker client required") + } + if err := validateWorkflowV4Plan(p, b); err != nil { + return zero, err + } + if p.Kind != "contribute" && p.Kind != "attest-erasure" { + return zero, errors.New("unsupported candidate inspection operation") + } + if err := validateCommitLocalPath(scopeFile); err != nil { + return zero, err + } + if _, err := pathWithin(b.Work, scopeFile, "/work"); err != nil { + return zero, err + } + r := b.Runtimes["contributor"] + inputRoot, err := workflowV4PredecessorInputRoot(p, b.Work) + if err != nil { + return zero, err + } + d := &dockerDriver{image: r.Image, platform: r.Platform, root: inputRoot, inspectionRoot: b.Work, client: client} + if daemon.Endpoint != "" { + if err := validateLocalDockerEndpoint(daemon.Endpoint); err != nil { + return zero, err + } + if !verifiedDaemonFacts(daemon) { + return zero, errors.New("incomplete original Docker endpoint identity") + } + d.daemon = daemon + d.client = client.BindHost(daemon.Endpoint) + } + for _, in := range p.Inputs { + switch in.Ref { + case b.Definition.Record: + d.definition = in.Path + case b.Definition.Signature: + d.definitionSig = in.Path + } + } + // The key path is taken from the validated saved command, not discovered + // from storage or selected from arbitrary files in the transcript. + for n, arg := range p.Command { + if arg == "--coordinator-public-key-file" { + for destination, source := range p.Runtime.Mounts { + if path, err := pathWithin(destination, p.Command[n+1], source); err == nil { + d.coordinatorKey = path + break + } + } + } + } + candidate := p.Outputs[0] + if p.Kind == "attest-erasure" { + candidate = filepath.Dir(candidate) + } + i := transcript.Inspector{Executable: "mpc-ceremony", CeremonyPath: d.definition, CeremonySignaturePath: d.definitionSig, CoordinatorPublicKeyPath: d.coordinatorKey, TranscriptRoot: d.root} + i.Runner = func(executable string, args ...string) ([]byte, []byte, error) { + if executable != "mpc-ceremony" || len(args) < 4 || !reflect.DeepEqual(args[:3], []string{"--format", "json", "inspect"}) || (args[3] != "computation-output-v4" && args[3] != "contribution-inventory-v4") { + return nil, nil, errors.New("candidate inspector cannot execute another command") + } + if err := d.authenticateDaemon(); err != nil { + return nil, nil, err + } + platform, stderr, err := d.client.Output("image", "inspect", "--format", "{{.Os}}/{{.Architecture}}", d.image) + if err != nil || strings.TrimSpace(string(platform)) != d.platform { + return nil, stderr, errors.New("saved contributor image/platform unavailable") + } + rewritten, mounts, err := d.rewriteArgs(args, map[string]string{candidate: "/relay/candidate", scopeFile: "/relay/scope.json"}) + if err != nil { + return nil, nil, err + } + for n := range mounts { + mounts[n].ReadOnly = true + } + command := append(d.baseRunArgs(true, mounts), d.image) + command = append(command, rewritten...) + stdout, stderr, err := d.client.Output(command...) + if err != nil { + return stdout, stderr, fmt.Errorf("inspect retained contribution in saved runtime: %w", err) + } + return stdout, stderr, nil + } + return i, nil +} + +func workflowV4PredecessorInputRoot(p workflowV4OperationPlan, work string) (string, error) { + inputRoot := filepath.Join(work, "workflow-v4", "inputs", p.ID) + for _, input := range p.Inputs { + if input.Ref != p.Predecessor.Record { + continue + } + root := filepath.Clean(input.Path) + for range strings.Split(input.Ref.Name, "/") { + root = filepath.Dir(root) + } + if filepath.Join(root, filepath.FromSlash(input.Ref.Name)) != filepath.Clean(input.Path) { + return "", errors.New("predecessor path does not match its authenticated name") + } + inputRoot = root + return inputRoot, nil + } + return "", errors.New("retained predecessor record is missing") +} diff --git a/cmd/relay/workflow_v4_inspector_test.go b/cmd/relay/workflow_v4_inspector_test.go new file mode 100644 index 0000000..dde8e50 --- /dev/null +++ b/cmd/relay/workflow_v4_inspector_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +type inspectionClientV4 struct { + *dockerClientFake + invocation []string +} + +func (f *inspectionClientV4) BindHost(host string) dockerCommandClient { f.host = host; return f } +func (f *inspectionClientV4) Output(args ...string) ([]byte, []byte, error) { + if len(args) > 0 && args[0] == "run" { + f.invocation = append([]string(nil), args...) + return []byte("{}"), nil, nil + } + return f.dockerClientFake.Output(args...) +} + +func TestWorkflowV4CandidateInspectionMountsNoSecrets(t *testing.T) { + _, b := workflowV4TestBinding(t) + p := workflowV4TestPlan(t, b) + if err := os.Mkdir(p.Outputs[0], 0700); err != nil { + t.Fatal(err) + } + scope := filepath.Join(b.Work, "scope.json") + if err := os.WriteFile(scope, []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + f := &inspectionClientV4{dockerClientFake: &dockerClientFake{platform: p.Runtime.Platform}} + i, err := workflowV4CandidateInspector(p, b, scope, f, dockerDaemonFacts{}) + if err != nil { + t.Fatal(err) + } + if _, _, err := i.Runner("mpc-ceremony", "--format", "json", "phase1", "contribute"); err == nil { + t.Fatal("inspector allowed computation") + } + args := []string{"--format", "json", "inspect", "computation-output-v4", "--ceremony", i.CeremonyPath, "--ceremony-signature", i.CeremonySignaturePath, "--coordinator-public-key-file", i.CoordinatorPublicKeyPath, "--transcript-root", i.TranscriptRoot, "--chain", p.Inputs[0].Path, "--chain-signature", p.Inputs[1].Path, "--scope", scope, "--candidate-dir", p.Outputs[0]} + if _, _, err := i.Runner("mpc-ceremony", args...); err != nil { + t.Fatal(err) + } + joined := strings.Join(f.invocation, " ") + if strings.Contains(joined, b.Runtimes["signer"].Mounts["/keys"]) || strings.Contains(joined, "dst=/work,") { + t.Fatal("inspection mounts private keys or entire work directory") + } + for n, arg := range f.invocation { + if arg == "--mount" && !strings.Contains(f.invocation[n+1], "readonly") { + t.Fatal("writable inspection mount") + } + } + if !strings.Contains(joined, "--network none") || !strings.Contains(joined, p.Runtime.Image) { + t.Fatal("missing network isolation or saved image") + } + // Changing the default context must not redirect restart verification. + originalEndpoint := "unix:///var/run/original-relay-test.sock" + daemon, err := inspectDockerDaemon(f, "original", originalEndpoint) + if err != nil { + t.Fatal(err) + } + f.endpoint = "unix:///var/run/other-relay-test.sock" + i, err = workflowV4CandidateInspector(p, b, scope, f, daemon) + if err != nil { + t.Fatal(err) + } + if _, _, err := i.Runner("mpc-ceremony", args...); err != nil { + t.Fatal(err) + } + if f.host != originalEndpoint { + t.Fatal("inspection followed another Docker context") + } +} + +func TestWorkflowV4CleanupInspectionUsesContributionSnapshot(t *testing.T) { + _, b := workflowV4TestBinding(t) + p := workflowV4TestPlan(t, b) + contributionRoot := filepath.Join(b.Work, "workflow-v4", "inputs", p.ID) + p.ID = strings.Repeat("2", 32) + root, err := workflowV4PredecessorInputRoot(p, b.Work) + if err != nil { + t.Fatal(err) + } + if root != contributionRoot { + t.Fatalf("transcript root = %q, want retained contribution root %q", root, contributionRoot) + } +} diff --git a/cmd/relay/workflow_v4_io.go b/cmd/relay/workflow_v4_io.go new file mode 100644 index 0000000..da5500c --- /dev/null +++ b/cmd/relay/workflow_v4_io.go @@ -0,0 +1,60 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" +) + +const workflowV4MaximumBytes = 16 << 20 + +// readWorkflowV4JSON reads private local recovery state, not authenticated +// ceremony evidence. Callers must additionally validate its schema and bindings. +// The workspace lock must be held throughout reading and any later mutation. +func readWorkflowV4JSON(path string, target any) error { + before, err := os.Lstat(path) + if err != nil { + return err + } + if !before.Mode().IsRegular() || before.Mode().Perm()&0077 != 0 || before.Size() > workflowV4MaximumBytes { + return errors.New("V4 recovery state must be a private regular file within its size limit") + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + opened, err := f.Stat() + if err != nil { + return err + } + if !os.SameFile(before, opened) { + return errors.New("V4 recovery state changed while opening") + } + raw, err := io.ReadAll(io.LimitReader(f, workflowV4MaximumBytes+1)) + if err != nil { + return err + } + after, err := f.Stat() + if err != nil { + return err + } + if len(raw) > workflowV4MaximumBytes || int64(len(raw)) != before.Size() || after.Size() != before.Size() || !after.ModTime().Equal(before.ModTime()) { + return errors.New("V4 recovery state changed or exceeded its size limit") + } + if err := rejectCommitJournalDuplicateFields(raw); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return errors.New("V4 recovery state has trailing JSON") + } + return nil +} diff --git a/cmd/relay/workflow_v4_io_test.go b/cmd/relay/workflow_v4_io_test.go new file mode 100644 index 0000000..06fc28f --- /dev/null +++ b/cmd/relay/workflow_v4_io_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWorkflowV4PrivateStrictRead(t *testing.T) { + for _, tc := range []struct{ name, raw string }{ + {"duplicate", `{"value":"a","value":"b"}`}, + {"unknown", `{"other":"a"}`}, + {"trailing", `{"value":"a"} {}`}, + {"malformed", `{"value":`}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(path, []byte(tc.raw), 0600); err != nil { + t.Fatal(err) + } + var state struct { + Value string `json:"value"` + } + if err := readWorkflowV4JSON(path, &state); err == nil { + t.Fatal("accepted invalid state") + } + }) + } + path := filepath.Join(t.TempDir(), "state.json") + want := struct { + Value string `json:"value"` + }{strings.Repeat("x", 2<<20)} + if err := saveJSONAtomicWithLimit(path, want, workflowV4MaximumBytes); err != nil { + t.Fatal(err) + } + var got struct { + Value string `json:"value"` + } + if err := readWorkflowV4JSON(path, &got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatal("round trip changed state") + } + if err := saveJSONAtomic(path, want); err == nil { + t.Fatal("legacy size limit changed") + } + if err := saveJSONAtomicWithLimit(path, want, 0); err == nil { + t.Fatal("accepted invalid bound") + } + if err := readWorkflowV4JSON(path, &got); err != nil || got != want { + t.Fatal("failed save replaced old state", err) + } + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(path, link); err != nil { + t.Fatal(err) + } + if err := readWorkflowV4JSON(link, &got); err == nil { + t.Fatal("accepted symlink") + } + if err := os.Chmod(path, 0644); err != nil { + t.Fatal(err) + } + if err := readWorkflowV4JSON(path, &got); err == nil { + t.Fatal("accepted public state") + } +} diff --git a/cmd/relay/workflow_v4_journal.go b/cmd/relay/workflow_v4_journal.go new file mode 100644 index 0000000..c43e641 --- /dev/null +++ b/cmd/relay/workflow_v4_journal.go @@ -0,0 +1,797 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "time" + + "golang.org/x/crypto/blake2b" + + "github.com/zksecurity/relay/internal/transcript" +) + +const workflowV4JournalSchema = "relay-workflow-v4-state-v1" +const workflowV4MarkerSchema = "relay-workflow-v4-workspace-v1" +const workflowV4MaximumOperations = 8192 + +type workflowV4Binding struct { + CeremonyID string `json:"ceremony_id"` + Definition transcript.SignedArtifactRefs `json:"definition"` + Name string `json:"name"` + Role string `json:"role"` + IdentityID string `json:"identity_id"` + Work string `json:"work"` + Runtimes map[string]workflowV4Runtime `json:"runtimes"` +} + +type workflowV4Marker struct { + Schema string `json:"schema"` + WorkspaceID string `json:"workspace_id"` + StatePath string `json:"state_path"` + Binding workflowV4Binding `json:"binding"` +} + +type workflowV4Runtime struct { + Image string `json:"image"` + Platform string `json:"platform"` + Mounts map[string]string `json:"mounts"` // container destination -> original host path +} + +type workflowV4Input struct { + Path string `json:"path"` + Ref transcript.ArtifactRef `json:"ref"` +} + +type workflowV4OperationPlan struct { + ID string `json:"id"` + Kind string `json:"kind"` + Scope transcript.ContributionScopeV4 `json:"scope"` + Predecessor transcript.SignedArtifactRefs `json:"predecessor"` + Allocation transcript.SignedArtifactRefs `json:"allocation,omitempty"` + AttemptID string `json:"attempt_id,omitempty"` + Runtime workflowV4Runtime `json:"runtime"` + Command []string `json:"command"` + Inputs []workflowV4Input `json:"inputs"` + Outputs []string `json:"outputs"` + CommitJournalPath string `json:"commit_journal_path,omitempty"` + CommitPlan *coordinatorCommitPlan `json:"commit_plan,omitempty"` +} + +type workflowV4Operation struct { + Plan workflowV4OperationPlan `json:"plan"` + Status string `json:"status"` + Prepared time.Time `json:"prepared"` + Started *time.Time `json:"started,omitempty"` + Returned *time.Time `json:"returned,omitempty"` + Resolved *time.Time `json:"resolved,omitempty"` +} + +type workflowV4State struct { + Schema string `json:"schema"` + Marker workflowV4Marker `json:"marker"` + Status string `json:"status"` + Operations []workflowV4Operation `json:"operations"` +} + +// The journal owns the workspace lock until close. It records execution +// boundaries only; even reconciled records must be reverified for guidance. +// It deliberately has no retry-running-operation method. +type workflowV4Journal struct { + path string + state workflowV4State + lock *participantRunLock + save func(string, any, int) error + writeErr error +} + +func openWorkflowV4Journal(protocol transcript.DefinitionProtocol, definition transcript.SignedArtifactRefs, binding workflowV4Binding) (*workflowV4Journal, error) { + if !protocol.UsesV4() || protocol.Definition.CeremonyID != binding.CeremonyID || definition != binding.Definition || definition != protocol.DefinitionRefs { + return nil, errors.New("V4 recovery requires the authenticated V4 ceremony definition") + } + journey, err := protocol.Definition.RequireJourney() + if err != nil { + return nil, err + } + assigned := false + for _, enrollment := range journey.RequiredEnrollments { + assigned = assigned || enrollment.Role == binding.Role && enrollment.Identity.ID == binding.IdentityID + } + if !assigned { + return nil, errors.New("V4 workspace identity is not assigned this role in the authenticated definition") + } + if err := validateWorkflowV4Binding(binding); err != nil { + return nil, err + } + lock, err := acquireParticipantRunLock("", binding.Work) + if err != nil { + return nil, err + } + j := &workflowV4Journal{path: filepath.Join(binding.Work, "workflow-v4", "state.json"), lock: lock, save: saveJSONAtomicWithLimit} + ready := false + defer func() { + if !ready { + _ = j.close() + } + }() + if err := ensurePrivateDirectory(filepath.Dir(j.path)); err != nil { + return nil, err + } + markerPath := filepath.Join(binding.Work, ".relay-workspace-v4.json") + var marker workflowV4Marker + markerErr := readWorkflowV4JSON(markerPath, &marker) + if markerErr != nil && !errors.Is(markerErr, os.ErrNotExist) { + return nil, markerErr + } + err = readWorkflowV4JSON(j.path, &j.state) + if errors.Is(err, os.ErrNotExist) { + if markerErr == nil { + return nil, errors.New("V4 workspace marker exists without its state; preserve and inspect this workspace") + } + id, err := randomID() + if err != nil { + return nil, err + } + j.state = workflowV4State{Schema: workflowV4JournalSchema, Status: "initializing", Marker: workflowV4Marker{Schema: workflowV4MarkerSchema, WorkspaceID: id, StatePath: j.path, Binding: binding}} + if err := j.persist(j.state); err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + if err := validateWorkflowV4State(j.state, binding, j.path); err != nil { + return nil, err + } + if markerErr == nil { + if !reflect.DeepEqual(marker, j.state.Marker) { + return nil, errors.New("V4 workspace marker does not match saved state") + } + } else { + if j.state.Status != "initializing" { + return nil, errors.New("V4 workspace marker is missing; preserve and inspect this workspace") + } + if err := writeJSONNoReplace(markerPath, j.state.Marker, 0600); err != nil { + return nil, err + } + } + if j.state.Status == "initializing" { + next := j.state + next.Status = "ready" + if err := j.persist(next); err != nil { + return nil, err + } + } + ready = true + return j, nil +} + +func (j *workflowV4Journal) close() error { + if j.lock == nil { + return nil + } + err := j.lock.release() + j.lock = nil + return err +} + +func (j *workflowV4Journal) persist(next workflowV4State) error { + if j.lock == nil { + return errors.New("V4 workspace lock is not held") + } + if j.writeErr != nil { + return errors.New("V4 state save failed; close and reopen to inspect durable state before doing more work") + } + if err := validateWorkflowV4State(next, j.state.Marker.Binding, j.path); err != nil { + return err + } + // Clone before saving: caller-owned slices/maps must not mutate a saved plan. + raw, err := json.Marshal(next) + if err != nil { + return err + } + var copy workflowV4State + if err := json.Unmarshal(raw, ©); err != nil { + return err + } + if err := j.save(j.path, copy, workflowV4MaximumBytes); err != nil { + // A rename may have succeeded before directory fsync failed. Do not + // let the old in-memory state authorize another execution in this process. + j.writeErr = err + return err + } + j.state = copy + return nil +} + +func (j *workflowV4Journal) pending() (*workflowV4Operation, error) { + if j.lock == nil || j.writeErr != nil { + return nil, errors.New("reopen the V4 workspace before inspecting saved operations") + } + for _, operation := range j.state.Operations { + if !workflowV4OperationResolved(operation.Status) { + raw, err := json.Marshal(operation) + if err != nil { + return nil, err + } + var copy workflowV4Operation + if err := json.Unmarshal(raw, ©); err != nil { + return nil, err + } + return ©, nil + } + } + return nil, nil +} + +func (j *workflowV4Journal) prepare(plan workflowV4OperationPlan) error { + if j.state.Status != "ready" { + return errors.New("V4 workspace initialization is incomplete") + } + if pending, err := j.pending(); err != nil { + return err + } else if pending != nil { + return errors.New("inspect the pending V4 operation before preparing new work") + } + if err := validateWorkflowV4Plan(plan, j.state.Marker.Binding); err != nil { + return err + } + for _, earlier := range j.state.Operations { + if earlier.Status != "abandoned" && earlier.Status != "failed-no-effects" && plan.Kind == "contribute" && earlier.Plan.Kind == plan.Kind && earlier.Plan.Scope == plan.Scope && (earlier.Plan.AttemptID == plan.AttemptID || earlier.Plan.Allocation == plan.Allocation) { + return errors.New("this signed allocation already has a computation operation; inspect its retained result instead of computing again") + } + } + if err := workflowV4OutputsAbsent(plan); err != nil { + return err + } + if err := workflowV4InputsMatch(plan); err != nil { + return err + } + next := j.state + next.Operations = append(append([]workflowV4Operation(nil), next.Operations...), workflowV4Operation{Plan: plan, Status: "prepared", Prepared: time.Now().UTC()}) + return j.persist(next) +} + +// runPrepared persists running before invoking the child. Errors (including +// cancellation) retain running: process exit alone cannot establish no effects. +func (j *workflowV4Journal) runPrepared(id string, run func(workflowV4OperationPlan) error) error { + operation, err := j.pending() + if err != nil { + return err + } + if operation == nil || operation.Plan.ID != id || operation.Status != "prepared" || run == nil { + return errors.New("only a prepared V4 operation may start") + } + if err := workflowV4OutputsAbsent(operation.Plan); err != nil { + return err + } + if err := workflowV4InputsMatch(operation.Plan); err != nil { + return err + } + if err := j.transition(id, "running"); err != nil { + return err + } + if err := run(operation.Plan); err != nil { + return err + } + return j.transition(id, "returned-needs-verification") +} + +func (j *workflowV4Journal) abandonPrepared(id string) error { + operation, err := j.pending() + if err != nil { + return err + } + if operation == nil || operation.Plan.ID != id || operation.Status != "prepared" { + return errors.New("only an unstarted operation may be abandoned") + } + if err := workflowV4OutputsAbsent(operation.Plan); err != nil { + return err + } + return j.transition(id, "abandoned") +} + +// verify must inspect exact retained artifacts (and publication for coordinator +// commits), never merely the child exit status. No automatic execution occurs. +func (j *workflowV4Journal) reconcile(id string, verify func(workflowV4OperationPlan) error) error { + operation, err := j.pending() + if err != nil { + return err + } + if operation == nil || operation.Plan.ID != id || (operation.Status != "running" && operation.Status != "returned-needs-verification") || verify == nil { + return errors.New("V4 reconciliation requires an uncertain operation and exact verification") + } + if err := verify(operation.Plan); err != nil { + return err + } + if operation.Plan.CommitJournalPath != "" { + var commit coordinatorCommitJournalRecord + if err := readWorkflowV4JSON(operation.Plan.CommitJournalPath, &commit); err != nil { + return err + } + if err := commit.validate(); err != nil { + return err + } + if commit.Stage != commitRootCASCommitted || operation.Plan.CommitPlan == nil || !reflect.DeepEqual(commit.Plan, *operation.Plan.CommitPlan) { + return errors.New("coordinator publication is not committed for this exact V4 operation") + } + } + return j.transition(id, "reconciled") +} + +func (j *workflowV4Journal) transition(id, status string) error { + next := j.state + next.Operations = append([]workflowV4Operation(nil), next.Operations...) + if len(next.Operations) == 0 || next.Operations[len(next.Operations)-1].Plan.ID != id { + return errors.New("V4 operation is not current") + } + op := &next.Operations[len(next.Operations)-1] + now := time.Now().UTC() + switch status { + case "running": + if op.Status != "prepared" { + return errors.New("V4 operation has already started") + } + op.Started = &now + case "returned-needs-verification": + if op.Status != "running" { + return errors.New("V4 operation is not running") + } + op.Returned = &now + case "reconciled": + if op.Status != "running" && op.Status != "returned-needs-verification" { + return errors.New("V4 operation cannot be reconciled") + } + op.Resolved = &now + case "abandoned": + if op.Status != "prepared" { + return errors.New("V4 operation may have run") + } + op.Resolved = &now + case "failed-no-effects": + if op.Status != "running" { + return errors.New("only a running operation may be resolved as having no effects") + } + op.Resolved = &now + default: + return errors.New("unsupported V4 operation status") + } + op.Status = status + return j.persist(next) +} + +func validateWorkflowV4Binding(b workflowV4Binding) error { + if !validCoordinatorCommitDigest(b.CeremonyID) || b.Name == "" || b.IdentityID == "" || (b.Role != "coordinator" && b.Role != "participant" && b.Role != "release-signer" && b.Role != "auditor") { + return errors.New("invalid V4 workspace ceremony or role binding") + } + if err := validateCommitLocalPath(b.Work); err != nil { + return err + } + if err := validateETag("workspace name", b.Name); err != nil { + return err + } + if err := validateETag("role identity", b.IdentityID); err != nil { + return err + } + if len(b.Runtimes) < 1 || len(b.Runtimes) > 3 { + return errors.New("V4 workspace requires its approved runtime profiles") + } + for class, runtime := range b.Runtimes { + if class != "contributor" && class != "signer" && class != "online" { + return errors.New("unknown V4 runtime class") + } + if err := validateWorkflowV4Runtime(runtime, b.Work); err != nil { + return err + } + } + return validateWorkflowV4Pair(b.Definition) +} + +func validateWorkflowV4Pair(pair transcript.SignedArtifactRefs) error { + if pair.Record.Name == pair.Signature.Name { + return errors.New("V4 record and signature must be distinct") + } + if pair.Record.Digest.Size > 16<<20 || pair.Signature.Digest.Size > 4096 { + return errors.New("V4 retained record or signature exceeds its size limit") + } + for _, ref := range []transcript.ArtifactRef{pair.Record, pair.Signature} { + if err := validateWorkflowV4Ref(ref); err != nil { + return err + } + if ref.Digest.Blake2b256 == "" { + return errors.New("signed V4 protocol references require both authenticated digests") + } + } + return nil +} + +func validateWorkflowV4Ref(ref transcript.ArtifactRef) error { + if err := transcript.ValidateName(ref.Name); err != nil { + return err + } + if !validCoordinatorCommitDigest(ref.Digest.SHA256) || ref.Digest.Size < 1 || ref.Digest.Size > 16<<30 { + return errors.New("invalid V4 retained file digest") + } + if !validCoordinatorCommitDigest(strings.Replace(ref.Digest.Blake2b256, "blake2b256:", "sha256:", 1)) || !strings.HasPrefix(ref.Digest.Blake2b256, "blake2b256:") { + return errors.New("invalid V4 retained BLAKE2b-256 digest") + } + return nil +} + +func validateWorkflowV4State(s workflowV4State, binding workflowV4Binding, path string) error { + if s.Schema != workflowV4JournalSchema || s.Marker.Schema != workflowV4MarkerSchema || !validFlowAttemptID(s.Marker.WorkspaceID) || s.Marker.StatePath != path || !reflect.DeepEqual(s.Marker.Binding, binding) { + return errors.New("V4 recovery state belongs to another workspace or definition") + } + if err := validateWorkflowV4Binding(binding); err != nil { + return err + } + if (s.Status != "initializing" && s.Status != "ready") || (s.Status == "initializing" && len(s.Operations) != 0) || len(s.Operations) > workflowV4MaximumOperations { + return errors.New("invalid or full V4 operation journal") + } + ids := make(map[string]bool) + type computationAttemptKey struct { + Scope transcript.ContributionScopeV4 + AttemptID string + } + type computationAllocationKey struct { + Scope transcript.ContributionScopeV4 + Allocation transcript.SignedArtifactRefs + } + computationAttempts := make(map[computationAttemptKey]bool) + computationAllocations := make(map[computationAllocationKey]bool) + for n, op := range s.Operations { + if ids[op.Plan.ID] { + return errors.New("duplicate V4 operation ID") + } + ids[op.Plan.ID] = true + if err := validateWorkflowV4Plan(op.Plan, binding); err != nil { + return err + } + if op.Plan.Kind == "contribute" && op.Status != "abandoned" && op.Status != "failed-no-effects" { + attempt := computationAttemptKey{Scope: op.Plan.Scope, AttemptID: op.Plan.AttemptID} + allocation := computationAllocationKey{Scope: op.Plan.Scope, Allocation: op.Plan.Allocation} + if computationAttempts[attempt] || computationAllocations[allocation] { + return errors.New("V4 journal repeats a computation for the same signed allocation") + } + computationAttempts[attempt] = true + computationAllocations[allocation] = true + } + if op.Prepared.IsZero() { + return errors.New("missing V4 operation preparation time") + } + switch op.Status { + case "prepared": + if op.Started != nil || op.Returned != nil || op.Resolved != nil { + return errors.New("invalid prepared V4 operation") + } + case "running": + if op.Started == nil || op.Returned != nil || op.Resolved != nil { + return errors.New("invalid running V4 operation") + } + case "returned-needs-verification": + if op.Started == nil || op.Returned == nil || op.Resolved != nil { + return errors.New("invalid returned V4 operation") + } + case "reconciled": + if op.Started == nil || op.Resolved == nil { + return errors.New("invalid reconciled V4 operation") + } + case "abandoned": + if op.Started != nil || op.Returned != nil || op.Resolved == nil { + return errors.New("invalid abandoned V4 operation") + } + case "failed-no-effects": + if op.Started == nil || op.Returned != nil || op.Resolved == nil { + return errors.New("invalid no-effects V4 operation") + } + default: + return errors.New("unknown V4 operation status") + } + previous := op.Prepared + for _, stamp := range []*time.Time{&op.Prepared, op.Started, op.Returned, op.Resolved} { + if stamp == nil { + continue + } + _, offset := stamp.Zone() + if stamp.IsZero() || stamp.Before(previous) || offset != 0 { + return errors.New("invalid V4 operation timestamp ordering") + } + previous = *stamp + } + if n != len(s.Operations)-1 && !workflowV4OperationResolved(op.Status) { + return errors.New("new work follows an unresolved V4 operation") + } + } + return nil +} + +func workflowV4OperationResolved(status string) bool { + return status == "reconciled" || status == "abandoned" || status == "failed-no-effects" +} + +func validateWorkflowV4Plan(p workflowV4OperationPlan, b workflowV4Binding) error { + if !validFlowAttemptID(p.ID) || p.Scope.CeremonyID != b.CeremonyID || !validCoordinatorCommitDigest(p.Scope.ParentHeadID) || (p.Scope.Phase != "phase1" && p.Scope.Phase != "phase2") || p.Scope.Index < 1 || p.Scope.Index > 20 || p.Scope.ParticipantID == "" || (b.Role == "participant" && p.Scope.ParticipantID != b.IdentityID) { + return errors.New("invalid V4 operation turn") + } + if err := validateWorkflowV4Pair(p.Predecessor); err != nil { + return err + } + if p.Kind == "contribute" { + if err := validateWorkflowV4Pair(p.Allocation); err != nil { + return errors.New("V4 contribution requires its exact signed allocation checkpoint") + } + } + wantRole, attemptBound, commit := "participant", false, false + switch p.Kind { + case "download-outbound", "upload-receipt", "upload-candidate": + attemptBound = true + case "contribute", "attest-erasure": + attemptBound = true + case "sign-receipt", "sign-return": + case "download-receipt", "download-candidate", "issue-grant": + wantRole, attemptBound = "coordinator", true + case "sign-return-receipt": + wantRole = "coordinator" + case "commit-outbound", "commit-receipt", "commit-candidate": + wantRole, attemptBound, commit = "coordinator", true, true + default: + return errors.New("unsupported V4 turn operation") + } + if b.Role != wantRole || (attemptBound && !validFlowAttemptID(p.AttemptID)) || (!attemptBound && p.AttemptID != "") { + return errors.New("V4 operation role or delivery attempt mismatch") + } + if commit != (p.CommitJournalPath != "") || commit != (p.CommitPlan != nil) { + return errors.New("V4 coordinator commit requires its exact publication journal") + } + if commit { + if err := p.CommitPlan.validate(); err != nil { + return err + } + if p.CommitPlan.OperationID != p.ID || p.CommitPlan.CeremonyID != b.CeremonyID { + return errors.New("V4 publication plan differs from this operation") + } + if !reflect.DeepEqual(p.CommitPlan.InnerOutputPaths, p.Outputs) { + return errors.New("V4 publication outputs differ from the saved operation") + } + if p.CommitJournalPath != filepath.Join(b.Work, "workflow-v4", "commits", p.ID+".json") { + return errors.New("V4 publication journal must use its isolated operation path") + } + if _, err := pathWithin(b.Work, p.CommitJournalPath, "/work"); err != nil { + return err + } + if err := validateCommitLocalPath(p.CommitJournalPath); err != nil { + return err + } + } + class := "online" + if p.Kind == "contribute" { + class = "contributor" + } + if p.Kind == "sign-receipt" || p.Kind == "sign-return" || p.Kind == "sign-return-receipt" || p.Kind == "attest-erasure" { + class = "signer" + } + if expected, ok := b.Runtimes[class]; !ok || !reflect.DeepEqual(expected, p.Runtime) { + return errors.New("V4 operation runtime differs from the approved role profile") + } + if err := validateWorkflowV4Runtime(p.Runtime, b.Work); err != nil { + return err + } + if len(p.Command) == 0 || len(p.Command) > 256 || len(p.Inputs) < 2 || len(p.Inputs) > 64 || len(p.Outputs) == 0 || len(p.Outputs) > 16 { + return errors.New("invalid V4 operation input/output bounds") + } + for _, arg := range p.Command { + if strings.ContainsRune(arg, '\x00') || len(arg) > 64<<10 { + return errors.New("invalid V4 command argument") + } + } + if err := validateWorkflowV4PlanPaths(p, b); err != nil { + return err + } + return validateWorkflowV4Command(p, b) +} + +func validateWorkflowV4Runtime(runtime workflowV4Runtime, work string) error { + if !roleImagePattern.MatchString(runtime.Image) || (runtime.Platform != "linux/amd64" && runtime.Platform != "linux/arm64") || runtime.Mounts["/work"] != work || len(runtime.Mounts) > 3 { + return errors.New("V4 operation requires the original pinned Linux runtime and work mount") + } + for destination, source := range runtime.Mounts { + if destination != "/work" && destination != "/trust" && destination != "/keys" { + return errors.New("unsupported V4 runtime mount") + } + if err := validateCommitLocalPath(source); err != nil { + return err + } + if destination != "/work" && workflowV4PathsOverlap(work, source) { + return errors.New("V4 trust and key mounts must be separate from public work") + } + } + if runtime.Mounts["/trust"] != "" && runtime.Mounts["/keys"] != "" && workflowV4PathsOverlap(runtime.Mounts["/trust"], runtime.Mounts["/keys"]) { + return errors.New("V4 public trust and private key mounts must not overlap") + } + return nil +} + +func validateWorkflowV4PlanPaths(p workflowV4OperationPlan, b workflowV4Binding) error { + seen := make(map[string]bool) + record, signature := false, false + for _, input := range p.Inputs { + if err := validateCommitLocalPath(input.Path); err != nil { + return err + } + if err := validateWorkflowV4Ref(input.Ref); err != nil { + return err + } + if seen[input.Path] { + return errors.New("duplicate V4 input path") + } + seen[input.Path] = true + inside := false + for destination, source := range p.Runtime.Mounts { + if destination != "/keys" { + if _, err := pathWithin(source, input.Path, destination); err == nil { + inside = true + } + } + } + if !inside { + return errors.New("V4 input is outside retained public mounts") + } + retained := filepath.Join(b.Work, "workflow-v4", "inputs", p.ID, filepath.FromSlash(input.Ref.Name)) + if p.Kind != "contribute" { + inputsRoot := filepath.Join(b.Work, "workflow-v4", "inputs") + if relative, err := filepath.Rel(inputsRoot, input.Path); err == nil { + parts := strings.Split(filepath.ToSlash(relative), "/") + if len(parts) > 1 && validFlowAttemptID(parts[0]) && strings.Join(parts[1:], "/") == input.Ref.Name { + retained = input.Path + } + } + } + record = record || (input.Ref == p.Predecessor.Record && input.Path == retained) + signature = signature || (input.Ref == p.Predecessor.Signature && input.Path == retained) + } + if !record || !signature { + return errors.New("V4 operation must retain exact predecessor files") + } + for _, output := range p.Outputs { + if err := validateCommitLocalPath(output); err != nil { + return err + } + if output == b.Work { + return errors.New("V4 output cannot replace the workspace") + } + if _, err := pathWithin(b.Work, output, "/work"); err != nil { + return err + } + rel, _ := filepath.Rel(b.Work, output) + resultPath := filepath.Join(b.Work, "workflow-v4", "results", p.ID+".json") + if (strings.HasPrefix(rel, "workflow") && !(p.Kind == "upload-candidate" && output == resultPath)) || strings.HasPrefix(rel, ".relay-") { + return errors.New("V4 output cannot replace recovery state") + } + for path := range seen { + if workflowV4PathsOverlap(path, output) { + return errors.New("V4 outputs overlap retained inputs or outputs") + } + } + seen[output] = true + } + return nil +} + +func workflowV4PathsOverlap(a, b string) bool { + _, first := pathWithin(a, b, "/") + _, second := pathWithin(b, a, "/") + return first == nil || second == nil +} + +func workflowV4OutputsAbsent(p workflowV4OperationPlan) error { + paths := append([]string(nil), p.Outputs...) + if p.CommitJournalPath != "" { + paths = append(paths, p.CommitJournalPath) + } + for _, path := range paths { + if err := workflowV4NoSymlinkPath(p.Runtime.Mounts["/work"], path, true); err != nil { + return err + } + if _, err := os.Lstat(path); err == nil { + return errors.New("V4 output already exists; inspect it without replaying the operation") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + } + return nil +} + +func workflowV4InputsMatch(p workflowV4OperationPlan) error { + for _, input := range p.Inputs { + checked := false + for destination, source := range p.Runtime.Mounts { + if destination == "/keys" { + continue + } + if _, err := pathWithin(source, input.Path, destination); err == nil { + if err := workflowV4NoSymlinkPath(source, input.Path, false); err != nil { + return err + } + checked = true + break + } + } + if !checked { + return errors.New("V4 input is outside retained public mounts") + } + info, err := os.Lstat(input.Path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() != input.Ref.Digest.Size { + return errors.New("V4 retained input is not a regular file") + } + if err := workflowV4HashInput(input, info); err != nil { + return err + } + } + return nil +} + +func workflowV4HashInput(input workflowV4Input, before os.FileInfo) error { + f, err := os.Open(input.Path) + if err != nil { + return err + } + defer f.Close() + opened, err := f.Stat() + if err != nil { + return err + } + if !os.SameFile(before, opened) || opened.Size() != before.Size() { + return errors.New("V4 retained input changed while opening") + } + sha := sha256.New() + blake, err := blake2b.New256(nil) + if err != nil { + return err + } + // Both digest domains bind retained bytes before the approved proof-tool + // verifies the complete signed reference during execution/reconciliation. + n, err := io.Copy(io.MultiWriter(sha, blake), io.LimitReader(f, before.Size()+1)) + if err != nil { + return err + } + after, err := f.Stat() + if err != nil { + return err + } + if n != before.Size() || after.Size() != before.Size() || !after.ModTime().Equal(before.ModTime()) || "sha256:"+hex.EncodeToString(sha.Sum(nil)) != input.Ref.Digest.SHA256 || "blake2b256:"+hex.EncodeToString(blake.Sum(nil)) != input.Ref.Digest.Blake2b256 { + return fmt.Errorf("V4 retained input changed: %s", input.Ref.Name) + } + return nil +} + +func workflowV4NoSymlinkPath(root, path string, allowMissing bool) error { + if _, err := pathWithin(root, path, "/"); err != nil { + return err + } + relative, _ := filepath.Rel(root, path) + current := root + components := append([]string{"."}, strings.Split(relative, string(filepath.Separator))...) + for _, component := range components { + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) && allowMissing { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return errors.New("V4 retained paths must not contain symlinks") + } + } + return nil +} diff --git a/cmd/relay/workflow_v4_journal_test.go b/cmd/relay/workflow_v4_journal_test.go new file mode 100644 index 0000000..7e7393f --- /dev/null +++ b/cmd/relay/workflow_v4_journal_test.go @@ -0,0 +1,689 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/transcript" + "golang.org/x/crypto/blake2b" +) + +func workflowV4TestRef(name, contents string) transcript.ArtifactRef { + hash := sha256.Sum256([]byte(contents)) + blake := blake2b.Sum256([]byte(contents)) + return transcript.ArtifactRef{Name: name, Digest: transcript.Digest{SHA256: "sha256:" + hex.EncodeToString(hash[:]), Blake2b256: "blake2b256:" + hex.EncodeToString(blake[:]), Size: int64(len(contents))}} +} + +func TestWorkflowV4LocalRefAndPlanRequireBothDigests(t *testing.T) { + path := filepath.Join(t.TempDir(), "input.json") + if err := os.WriteFile(path, []byte("retained public input"), 0600); err != nil { + t.Fatal(err) + } + ref, err := workflowV4LocalRef("input.json", path) + if err != nil || ref.Digest.Blake2b256 == "" { + t.Fatalf("local reference = %+v, %v", ref, err) + } + withoutBlake := ref + withoutBlake.Digest.Blake2b256 = "" + if err := validateWorkflowV4Ref(withoutBlake); err == nil { + t.Fatal("missing BLAKE2b digest accepted") + } +} + +func workflowV4TestBinding(t *testing.T) (transcript.DefinitionProtocol, workflowV4Binding) { + t.Helper() + work := t.TempDir() + if err := os.Chmod(work, 0700); err != nil { + t.Fatal(err) + } + b := workflowV4Binding{CeremonyID: "sha256:" + strings.Repeat("a", 64), Definition: transcript.SignedArtifactRefs{Record: workflowV4TestRef("ceremony.json", "definition"), Signature: workflowV4TestRef("ceremony.sig", "signature")}, Name: "test", Role: "participant", IdentityID: "participant-test", Work: work} + b.Runtimes = make(map[string]workflowV4Runtime) + trust, keys := t.TempDir(), t.TempDir() + for _, class := range []string{"online", "signer", "contributor"} { + b.Runtimes[class] = workflowV4Runtime{Image: "example.test/role@sha256:" + strings.Repeat("d", 64), Platform: "linux/arm64", Mounts: map[string]string{"/work": work, "/trust": trust, "/keys": keys}} + } + p := transcript.DefinitionProtocol{DefinitionSchema: "proof-tool-mpc-ceremony-definition-v4", StorageWorkflow: "storage-first-v2", ReleaseVerification: "coordinator-full-replay-v1"} + p.Definition.CeremonyID = b.CeremonyID + p.DefinitionRefs = b.Definition + p.Definition.Journey = &transcript.DefinitionJourney{Schema: "proof-tool-mpc-definition-journey-v2", ObserverRequirementSource: "signed-policy"} + for _, role := range []string{"coordinator", "release-signer", "participant"} { + p.Definition.Journey.RequiredEnrollments = append(p.Definition.Journey.RequiredEnrollments, transcript.ExpectedEnrollment{Role: role, RoleIndex: 1, Identity: transcript.PublicIdentity{ID: role + "-test", KeyID: "test-key-" + role, PublicKeyFingerprint: "test-fingerprint-" + role}}) + } + return p, b +} + +func workflowV4TestPlan(t *testing.T, b workflowV4Binding) workflowV4OperationPlan { + t.Helper() + id := strings.Repeat("1", 32) + attempt := strings.Repeat("a", 32) + record := workflowV4TestRef("phase1/chain.json", "chain") + signature := workflowV4TestRef("phase1/chain.sig", "signature") + checkpoint := workflowV4TestRef("checkpoints/0001/checkpoint.json", "checkpoint") + checkpointSignature := workflowV4TestRef("checkpoints/0001/checkpoint.sig", "checkpoint-signature") + p := workflowV4OperationPlan{ID: id, Kind: "contribute", Scope: transcript.ContributionScopeV4{CeremonyID: b.CeremonyID, Phase: "phase1", Index: 1, ParticipantID: b.IdentityID, ParentHeadID: "sha256:" + strings.Repeat("c", 64)}, Predecessor: transcript.SignedArtifactRefs{Record: record, Signature: signature}, Allocation: transcript.SignedArtifactRefs{Record: checkpoint, Signature: checkpointSignature}, AttemptID: attempt, Runtime: workflowV4Runtime{Image: "example.test/role@sha256:" + strings.Repeat("d", 64), Platform: "linux/arm64", Mounts: map[string]string{"/work": b.Work}}, Command: []string{"mpc-ceremony", "contribute"}, Outputs: []string{filepath.Join(b.Work, "candidate")}} + for destination, source := range b.Runtimes["contributor"].Mounts { + p.Runtime.Mounts[destination] = source + } + for n, ref := range []transcript.ArtifactRef{record, signature, checkpoint, checkpointSignature} { + path := filepath.Join(b.Work, "workflow-v4", "inputs", id, filepath.FromSlash(ref.Name)) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte([]string{"chain", "signature", "checkpoint", "checkpoint-signature"}[n]), 0600); err != nil { + t.Fatal(err) + } + p.Inputs = append(p.Inputs, workflowV4Input{Path: path, Ref: ref}) + } + root := filepath.Join(b.Work, "workflow-v4", "inputs", id) + for _, item := range []struct { + ref transcript.ArtifactRef + contents, path string + }{ + {b.Definition.Record, "definition", filepath.Join(root, "ceremony.json")}, + {b.Definition.Signature, "signature", filepath.Join(root, "ceremony.sig")}, + {workflowV4TestRef("coordinator.hex", "public-key"), "public-key", filepath.Join(p.Runtime.Mounts["/trust"], "coordinator.hex")}, + {workflowV4TestRef("environment.json", "environment"), "environment", filepath.Join(root, "environment.json")}, + } { + if err := os.WriteFile(item.path, []byte(item.contents), 0600); err != nil { + t.Fatal(err) + } + p.Inputs = append(p.Inputs, workflowV4Input{Path: item.path, Ref: item.ref}) + } + containerRoot := "/work/workflow-v4/inputs/" + id + p.Command = []string{"mpc-ceremony", "phase1", "contribute", "--ceremony", containerRoot + "/ceremony.json", "--ceremony-signature", containerRoot + "/ceremony.sig", "--coordinator-public-key-file", "/trust/coordinator.hex", "--transcript-dir", containerRoot, "--chain", containerRoot + "/phase1/chain.json", "--chain-signature", containerRoot + "/phase1/chain.sig", "--participant-id", b.IdentityID, "--participant-signing-key", "/keys/signing.hex", "--environment", containerRoot + "/environment.json", "--contributed-at", "2026-01-01T00:00:00Z", "--out-dir", "/work/candidate", "--artifact-root", containerRoot, "--checkpoint", containerRoot + "/checkpoints/0001/checkpoint.json", "--checkpoint-signature", containerRoot + "/checkpoints/0001/checkpoint.sig", "--attempt-id", attempt} + return p +} + +func retargetWorkflowV4TestPlan(t *testing.T, p workflowV4OperationPlan, id string) workflowV4OperationPlan { + t.Helper() + copy := p + copy.ID = id + copy.Inputs = append([]workflowV4Input(nil), p.Inputs...) + oldRoot := filepath.Join(p.Runtime.Mounts["/work"], "workflow-v4", "inputs", p.ID) + newRoot := filepath.Join(p.Runtime.Mounts["/work"], "workflow-v4", "inputs", id) + for n := range copy.Inputs { + if !strings.HasPrefix(copy.Inputs[n].Path, oldRoot+string(filepath.Separator)) { + continue + } + original := copy.Inputs[n].Path + copy.Inputs[n].Path = strings.Replace(original, oldRoot, newRoot, 1) + if err := os.MkdirAll(filepath.Dir(copy.Inputs[n].Path), 0700); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(original) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(copy.Inputs[n].Path, raw, 0600); err != nil { + t.Fatal(err) + } + } + copy.Command = append([]string(nil), p.Command...) + for n := range copy.Command { + copy.Command[n] = strings.ReplaceAll(copy.Command[n], p.ID, id) + } + copy.Outputs = []string{filepath.Join(p.Runtime.Mounts["/work"], "candidate-"+id)} + oldOutput, err := workflowV4ContainerPath(p.Runtime, p.Outputs[0]) + if err != nil { + t.Fatal(err) + } + newOutput, err := workflowV4ContainerPath(copy.Runtime, copy.Outputs[0]) + if err != nil { + t.Fatal(err) + } + for n := range copy.Command { + if copy.Command[n] == oldOutput { + copy.Command[n] = newOutput + } + } + return copy +} + +func TestWorkflowV4JournalRestartDoesNotReplay(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + plan := workflowV4TestPlan(t, binding) + if err := j.prepare(plan); err != nil { + t.Fatal(err) + } + // Changing caller-owned data cannot change the durable operation. + plan.Command[0] = "changed" + plan.Runtime.Mounts["/work"] = "/wrong" + op, err := j.pending() + if err != nil || op.Plan.Command[0] != "mpc-ceremony" || op.Plan.Runtime.Mounts["/work"] != binding.Work { + t.Fatal("plan alias", err) + } + if err := j.runPrepared(plan.ID, func(p workflowV4OperationPlan) error { return errors.New("interrupted") }); err == nil { + t.Fatal("lost child error") + } + if err := j.close(); err != nil { + t.Fatal(err) + } + j, err = openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + op, err = j.pending() + if err != nil || op.Status != "running" { + t.Fatal("lost uncertain state", err) + } + called := false + if err := j.runPrepared(plan.ID, func(workflowV4OperationPlan) error { called = true; return nil }); err == nil || called { + t.Fatal("replayed uncertain child") + } + if err := j.abandonPrepared(plan.ID); err == nil { + t.Fatal("abandoned uncertain child") + } + if err := j.reconcile(plan.ID, func(workflowV4OperationPlan) error { return errors.New("incomplete outputs") }); err == nil { + t.Fatal("ignored verification failure") + } + if err := j.reconcile(plan.ID, func(workflowV4OperationPlan) error { return nil }); err != nil { + t.Fatal(err) + } + if op, err := j.pending(); err != nil || op != nil { + t.Fatal("did not reconcile", err) + } + next := workflowV4TestPlan(t, binding) + next.ID = strings.Repeat("2", 32) + oldRoot := filepath.Join(binding.Work, "workflow-v4", "inputs", plan.ID) + newRoot := filepath.Join(binding.Work, "workflow-v4", "inputs", next.ID) + for n := range next.Inputs { + if !strings.HasPrefix(next.Inputs[n].Path, oldRoot+string(filepath.Separator)) { + continue + } + original := next.Inputs[n].Path + next.Inputs[n].Path = strings.Replace(original, oldRoot, newRoot, 1) + if err := os.MkdirAll(filepath.Dir(next.Inputs[n].Path), 0700); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(original) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(next.Inputs[n].Path, raw, 0600); err != nil { + t.Fatal(err) + } + } + for n := range next.Command { + next.Command[n] = strings.ReplaceAll(next.Command[n], plan.ID, next.ID) + } + // Even removing outputs cannot cause a second computation of this turn. + if err := j.prepare(next); err == nil || !strings.Contains(err.Error(), "already has a computation") { + t.Fatal("allowed recomputation") + } +} + +func TestWorkflowV4JournalPreparedAndSuccessfulBoundaries(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + plan := workflowV4TestPlan(t, binding) + if err := j.prepare(plan); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(plan.Outputs[0], []byte("unexpected"), 0600); err != nil { + t.Fatal(err) + } + if err := j.abandonPrepared(plan.ID); err == nil { + t.Fatal("abandoned existing output") + } + if err := j.runPrepared(plan.ID, func(workflowV4OperationPlan) error { t.Fatal("launched over output"); return nil }); err == nil { + t.Fatal("accepted existing output") + } + if err := os.Remove(plan.Outputs[0]); err != nil { + t.Fatal(err) + } + if err := j.runPrepared(plan.ID, func(workflowV4OperationPlan) error { + var disk workflowV4State + if err := readWorkflowV4JSON(j.path, &disk); err != nil { + t.Fatal(err) + } + if disk.Operations[0].Status != "running" { + t.Fatal("launched without durable intent") + } + return nil + }); err != nil { + t.Fatal(err) + } + op, err := j.pending() + if err != nil || op.Status != "returned-needs-verification" { + t.Fatal("child exit overclaims success", err) + } + if err := j.reconcile(plan.ID, nil); err == nil { + t.Fatal("reconciled without verification") + } +} + +func TestWorkflowV4JournalAllowsExplicitNewComputationAfterProvenNoEffect(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + first := workflowV4TestPlan(t, binding) + if err := j.prepare(first); err != nil { + t.Fatal(err) + } + if err := j.transition(first.ID, "running"); err != nil { + t.Fatal(err) + } + if err := j.transition(first.ID, "failed-no-effects"); err != nil { + t.Fatal(err) + } + if pending, err := j.pending(); err != nil || pending != nil { + t.Fatalf("resolved no-effect operation remains pending: %+v %v", pending, err) + } + second := retargetWorkflowV4TestPlan(t, first, strings.Repeat("2", 32)) + if err := j.prepare(second); err != nil { + t.Fatalf("explicit replacement computation was blocked: %v", err) + } +} + +func TestWorkflowV4JournalAllowsFreshReplacementAllocationAfterRejection(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + first := workflowV4TestPlan(t, binding) + if err := j.prepare(first); err != nil { + t.Fatal(err) + } + if err := j.transition(first.ID, "running"); err != nil { + t.Fatal(err) + } + if err := j.transition(first.ID, "reconciled"); err != nil { + t.Fatal(err) + } + + second := retargetWorkflowV4TestPlan(t, first, strings.Repeat("2", 32)) + oldAllocation := second.Allocation + second.AttemptID = strings.Repeat("b", 32) + second.Allocation = transcript.SignedArtifactRefs{ + Record: workflowV4TestRef("checkpoints/0002/checkpoint.json", "replacement-checkpoint"), + Signature: workflowV4TestRef("checkpoints/0002/checkpoint.sig", "replacement-checkpoint-signature"), + } + for n := range second.Inputs { + var contents string + switch second.Inputs[n].Ref { + case oldAllocation.Record: + second.Inputs[n].Ref = second.Allocation.Record + contents = "replacement-checkpoint" + case oldAllocation.Signature: + second.Inputs[n].Ref = second.Allocation.Signature + contents = "replacement-checkpoint-signature" + default: + continue + } + second.Inputs[n].Path = filepath.Join(binding.Work, "workflow-v4", "inputs", second.ID, filepath.FromSlash(second.Inputs[n].Ref.Name)) + if err := os.MkdirAll(filepath.Dir(second.Inputs[n].Path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(second.Inputs[n].Path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + } + for n := range second.Command { + switch second.Command[n] { + case first.AttemptID: + second.Command[n] = second.AttemptID + case "/work/workflow-v4/inputs/" + second.ID + "/" + oldAllocation.Record.Name: + second.Command[n] = "/work/workflow-v4/inputs/" + second.ID + "/" + second.Allocation.Record.Name + case "/work/workflow-v4/inputs/" + second.ID + "/" + oldAllocation.Signature.Name: + second.Command[n] = "/work/workflow-v4/inputs/" + second.ID + "/" + second.Allocation.Signature.Name + } + } + if err := j.prepare(second); err != nil { + t.Fatalf("fresh replacement allocation was blocked: %v", err) + } +} + +func TestWorkflowV4JournalFailedSaveStopsSameProcess(t *testing.T) { + for _, afterRename := range []bool{false, true} { + t.Run(map[bool]string{false: "before-rename", true: "after-rename"}[afterRename], func(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + plan := workflowV4TestPlan(t, binding) + if err := j.prepare(plan); err != nil { + t.Fatal(err) + } + j.save = func(path string, value any, maximum int) error { + if afterRename { + if err := saveJSONAtomicWithLimit(path, value, maximum); err != nil { + return err + } + } + return errors.New("injected save failure") + } + called := false + run := func(workflowV4OperationPlan) error { called = true; return nil } + if err := j.runPrepared(plan.ID, run); err == nil || called { + t.Fatal("launched after failed save") + } + j.save = saveJSONAtomicWithLimit + if err := j.runPrepared(plan.ID, run); err == nil || called { + t.Fatal("reused stale memory after failed save") + } + if err := j.close(); err != nil { + t.Fatal(err) + } + j, err = openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + op, err := j.pending() + if err != nil { + t.Fatal(err) + } + want := "prepared" + if afterRename { + want = "running" + } + if op.Status != want { + t.Fatalf("durable status %s, want %s", op.Status, want) + } + if !afterRename { + if err := j.abandonPrepared(plan.ID); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestWorkflowV4JournalBindingsLockAndLegacyIsolation(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + legacy := filepath.Join(binding.Work, "workflow", "state.json") + if err := os.MkdirAll(filepath.Dir(legacy), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(legacy, []byte("legacy untouched"), 0600); err != nil { + t.Fatal(err) + } + old := protocol + old.DefinitionSchema = "proof-tool-mpc-ceremony-definition-v3" + if _, err := openWorkflowV4Journal(old, binding.Definition, binding); err == nil { + t.Fatal("opened legacy as V4") + } + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + if second, err := openWorkflowV4Journal(protocol, binding.Definition, binding); err == nil { + second.close() + t.Fatal("opened concurrent journal") + } + if err := j.close(); err != nil { + t.Fatal(err) + } + wrong := binding + wrong.Name = "other" + if _, err := openWorkflowV4Journal(protocol, binding.Definition, wrong); err == nil { + t.Fatal("accepted wrong binding") + } + if raw, err := os.ReadFile(legacy); err != nil || string(raw) != "legacy untouched" { + t.Fatal("changed legacy state", err) + } + if err := os.Remove(j.path); err != nil { + t.Fatal(err) + } + if _, err := openWorkflowV4Journal(protocol, binding.Definition, binding); err == nil { + t.Fatal("recreated lost journal") + } +} + +func TestWorkflowV4JournalRejectsInvalidPlansAndChangedInputs(t *testing.T) { + for _, kind := range []string{"unknown", "missing-attempt", "unexpected-attempt", "mutable-runtime", "outside-output", "overlap", "changed-input", "symlink-parent", "mutable-predecessor"} { + t.Run(kind, func(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + plan := workflowV4TestPlan(t, binding) + switch kind { + case "unknown": + plan.Kind = "anything" + case "missing-attempt": + plan.Kind = "upload-candidate" + case "unexpected-attempt": + plan.AttemptID = strings.Repeat("3", 32) + case "mutable-runtime": + plan.Runtime.Image = "example.test/role:latest" + case "outside-output": + plan.Outputs[0] = filepath.Join(t.TempDir(), "outside") + case "overlap": + plan.Outputs[0] = plan.Inputs[0].Path + case "changed-input": + if err := os.WriteFile(plan.Inputs[0].Path, []byte("other"), 0600); err != nil { + t.Fatal(err) + } + case "symlink-parent": + link := filepath.Join(binding.Work, "link") + if err := os.Symlink(t.TempDir(), link); err != nil { + t.Fatal(err) + } + plan.Outputs[0] = filepath.Join(link, "output") + case "mutable-predecessor": + plan.Inputs[0].Path = filepath.Join(binding.Work, "chain-current.json") + } + if err := j.prepare(plan); err == nil { + t.Fatal("accepted invalid plan") + } + }) + } +} + +func TestWorkflowV4JournalInitializationRecovery(t *testing.T) { + for _, markerPresent := range []bool{false, true} { + t.Run(map[bool]string{false: "before-marker", true: "after-marker"}[markerPresent], func(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + state := j.state + state.Status = "initializing" + if err := j.close(); err != nil { + t.Fatal(err) + } + if err := saveJSONAtomicWithLimit(j.path, state, workflowV4MaximumBytes); err != nil { + t.Fatal(err) + } + if !markerPresent { + if err := os.Remove(filepath.Join(binding.Work, ".relay-workspace-v4.json")); err != nil { + t.Fatal(err) + } + } + resumed, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer resumed.close() + if resumed.state.Status != "ready" || !reflect.DeepEqual(resumed.state.Marker, state.Marker) { + t.Fatal("did not retain initialization binding") + } + }) + } +} + +func TestWorkflowV4JournalRejectsCorruptLoadedState(t *testing.T) { + for _, mutation := range []string{"duplicate-id", "unknown-kind", "unknown-status", "attempt", "missing-time", "wrong-state-path", "too-many-operations", "missing-marker"} { + t.Run(mutation, func(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + if err := j.prepare(workflowV4TestPlan(t, binding)); err != nil { + t.Fatal(err) + } + s := j.state + if err := j.close(); err != nil { + t.Fatal(err) + } + switch mutation { + case "duplicate-id": + s.Operations = append(s.Operations, s.Operations[0]) + case "unknown-kind": + s.Operations[0].Plan.Kind = "arbitrary-command" + case "unknown-status": + s.Operations[0].Status = "done" + case "attempt": + s.Operations[0].Plan.AttemptID = strings.Repeat("1", 32) + case "missing-time": + s.Operations[0].Status = "running" + case "wrong-state-path": + s.Marker.StatePath = filepath.Join(binding.Work, "elsewhere") + case "too-many-operations": + s.Operations = make([]workflowV4Operation, workflowV4MaximumOperations+1) + case "missing-marker": + if err := os.Remove(filepath.Join(binding.Work, ".relay-workspace-v4.json")); err != nil { + t.Fatal(err) + } + } + if err := saveJSONAtomicWithLimit(j.path, s, workflowV4MaximumBytes); err != nil { + t.Fatal(err) + } + if bad, err := openWorkflowV4Journal(protocol, binding.Definition, binding); err == nil { + bad.close() + t.Fatal("accepted corrupt journal") + } + }) + } +} + +func TestWorkflowV4JournalFailedReturnedSaveRetainsUncertainty(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + plan := workflowV4TestPlan(t, binding) + if err := j.prepare(plan); err != nil { + t.Fatal(err) + } + if err := j.runPrepared(plan.ID, func(workflowV4OperationPlan) error { + j.save = func(string, any, int) error { return errors.New("return-status save failed") } + return nil + }); err == nil { + t.Fatal("ignored failed return-status save") + } + verified := false + if err := j.reconcile(plan.ID, func(workflowV4OperationPlan) error { verified = true; return nil }); err == nil || verified { + t.Fatal("continued after failed save") + } + if err := j.close(); err != nil { + t.Fatal(err) + } + j, err = openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + op, err := j.pending() + if err != nil || op.Status != "running" { + t.Fatal("lost pending child", err) + } +} + +func TestWorkflowV4JournalCoordinatorNeedsExactCommittedPublication(t *testing.T) { + protocol, binding := workflowV4TestBinding(t) + binding.Role = "coordinator" + binding.IdentityID = "coordinator-test" + binding.CeremonyID = commitDigest("1") + protocol.Definition.CeremonyID = binding.CeremonyID + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + defer j.close() + plan := workflowV4TestPlan(t, binding) + plan.Kind, plan.AttemptID = "commit-outbound", strings.Repeat("2", 32) + plan.Command = []string{"relay-internal", "commit-outbound"} + commitPlan := testCoordinatorCommitPlan(filepath.Join(binding.Work, "publication")) + plan.CommitPlan = &commitPlan + plan.Outputs = append([]string(nil), commitPlan.InnerOutputPaths...) + plan.CommitJournalPath = filepath.Join(binding.Work, "workflow-v4", "commits", plan.ID+".json") + if err := j.prepare(plan); err != nil { + t.Fatal(err) + } + if err := j.runPrepared(plan.ID, func(workflowV4OperationPlan) error { return nil }); err != nil { + t.Fatal(err) + } + verify := func(workflowV4OperationPlan) error { return nil } // synthetic verification result, not ceremony evidence + if err := j.reconcile(plan.ID, verify); err == nil { + t.Fatal("accepted missing publication journal") + } + commit, err := openOrCreateCoordinatorCommitJournal(plan.CommitJournalPath, commitPlan) + if err != nil { + t.Fatal(err) + } + if err := j.reconcile(plan.ID, verify); err == nil { + t.Fatal("child return treated as publication") + } + root := filepath.Dir(commitPlan.InnerOutputPaths[0]) + if err := commit.recordInnerSigned([]coordinatorCommitOutput{commitOutput(commitPlan.InnerOutputPaths[0], "3"), commitOutput(commitPlan.InnerOutputPaths[1], "4")}); err != nil { + t.Fatal(err) + } + checkpoint := testCheckpointIntent(root) + if err := commit.checkpointSigningIntent(checkpoint); err != nil { + t.Fatal(err) + } + if err := commit.recordCheckpointSigned(commitOutput(checkpoint.CheckpointOutputPath, "5"), commitOutput(checkpoint.CheckpointSignatureOutputPath, "6")); err != nil { + t.Fatal(err) + } + if err := commit.recordAuthenticatedChild(testAuthenticatedChild(root, commitPlan)); err != nil { + t.Fatal(err) + } + intent := testRootIntent(root) + if err := commit.rootCASIntent(intent, commitOutput(intent.RootPayloadOutputPath, "7")); err != nil { + t.Fatal(err) + } + if err := j.reconcile(plan.ID, verify); err == nil { + t.Fatal("publication intent treated as committed") + } + if err := commit.recordRootCASCommitted(committedRootVersion()); err != nil { + t.Fatal(err) + } + // A committed status for another predecessor version is insufficient. + exact := commit.record + changed := exact + changed.Plan = cloneCoordinatorCommitPlan(exact.Plan) + changed.Plan.PreviousRootVersion.VersionID = "different-version" + if err := saveJSONAtomic(plan.CommitJournalPath, changed); err != nil { + t.Fatal(err) + } + if err := j.reconcile(plan.ID, verify); err == nil { + t.Fatal("accepted different commit plan") + } + if err := saveJSONAtomic(plan.CommitJournalPath, exact); err != nil { + t.Fatal(err) + } + if err := j.reconcile(plan.ID, verify); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/relay/workflow_v4_lifecycle.go b/cmd/relay/workflow_v4_lifecycle.go new file mode 100644 index 0000000..bfa14f9 --- /dev/null +++ b/cmd/relay/workflow_v4_lifecycle.go @@ -0,0 +1,48 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "path/filepath" +) + +func workflowV4ContributorIntentPath(work, id string) string { + return filepath.Join(work, "workflow-v4", "contributor-"+id+".json") +} + +func (j *workflowV4Journal) validateCleanupContribution(p workflowV4OperationPlan, r dockerLifecycleReceipt, candidate string) error { + var prior *workflowV4Operation + for n := range j.state.Operations { + op := &j.state.Operations[n] + if op.Plan.Kind == "contribute" && op.Plan.Scope == p.Scope && op.Status == "reconciled" && op.Plan.ID == r.OperationID { + prior = op + break + } + } + if prior == nil || prior.Plan.Predecessor != p.Predecessor || len(prior.Plan.Outputs) != 1 || prior.Plan.Outputs[0] != candidate || prior.Plan.Runtime.Image != r.Image || prior.Plan.Runtime.Platform != r.Platform { + return errors.New("cleanup lifecycle does not match the reconciled contribution operation") + } + return j.validateContributionLifecycle(prior.Plan, r, candidate) +} + +func (j *workflowV4Journal) validateContributionLifecycle(p workflowV4OperationPlan, r dockerLifecycleReceipt, candidate string) error { + if p.Kind != "contribute" || p.ID != r.OperationID || len(p.Outputs) != 1 || p.Outputs[0] != candidate || p.Runtime.Image != r.Image || p.Runtime.Platform != r.Platform { + return errors.New("lifecycle does not match contribution plan") + } + var intent dockerActiveState + if err := readWorkflowV4JSON(workflowV4ContributorIntentPath(j.state.Marker.Binding.Work, p.ID), &intent); err != nil { + return err + } + if !validDockerActiveState(intent) || intent.OperationID != p.ID || intent.WorkspaceID != r.WorkspaceID || intent.Image != r.Image || intent.Platform != r.Platform || intent.DaemonID != r.Daemon.ID || intent.DaemonEndpoint != r.Daemon.Endpoint || len(intent.CreateArgs) == 0 { + return errors.New("cleanup lifecycle differs from the original Docker invocation") + } + destination := sha256.Sum256([]byte(filepath.Clean(candidate))) + invocation, _ := json.Marshal(intent.CreateArgs) + digest := sha256.Sum256(invocation) + if r.CandidateDirectorySHA256 != fmt.Sprintf("sha256:%x", destination) || r.CreateArgsSHA256 != fmt.Sprintf("sha256:%x", digest) { + return errors.New("cleanup lifecycle has another candidate or Docker command") + } + return nil +} diff --git a/cmd/relay/workflow_v4_live_test.go b/cmd/relay/workflow_v4_live_test.go new file mode 100644 index 0000000..4d5290d --- /dev/null +++ b/cmd/relay/workflow_v4_live_test.go @@ -0,0 +1,403 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +// TestV4LiveInitialR2 is an explicit live-provider check. It creates a unique +// tiny rehearsal definition, publishes its complete initial V4 state through +// the real R2 S3 API, and authenticates it back. It never uses production keys. +func TestV4LiveInitialR2(t *testing.T) { + image := os.Getenv("RELAY_PREPARE_TEST_IMAGE") + onlineImage := os.Getenv("RELAY_V4_LIVE_ONLINE_IMAGE") + configPath := os.Getenv("RELAY_V4_LIVE_R2_CONFIG") + credentialsPath := os.Getenv("RELAY_V4_LIVE_R2_CREDENTIALS") + proofBinary := os.Getenv("RELAY_V4_LIVE_PROOF_BINARY") + if image == "" || onlineImage == "" || configPath == "" || credentialsPath == "" || proofBinary == "" { + t.Skip("set RELAY_PREPARE_TEST_IMAGE, RELAY_V4_LIVE_ONLINE_IMAGE, RELAY_V4_LIVE_R2_CONFIG, RELAY_V4_LIVE_R2_CREDENTIALS and RELAY_V4_LIVE_PROOF_BINARY") + } + for _, path := range []string{configPath, credentialsPath, proofBinary} { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + t.Fatal("live test paths must be absolute and clean") + } + } + rawConfig, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + base, err := access.Decode(rawConfig, access.StorageConfig.Validate) + if err != nil || base.Provider != "r2" { + t.Fatalf("live test requires an existing valid R2 configuration: %v", err) + } + 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 + // signed rehearsal allowlist just as the coordinator setup does for mixed + // platforms. Linux runs use the image's primary binary and must not add a + // duplicate platform entry. + if runtime.GOOS != "linux" { + digest, err := setupFileHash(proofBinary) + if err != nil { + t.Fatal(err) + } + w.d.Binaries = []setupBinary{{Path: proofBinary, SHA256: digest}} + } + w.d.Policy.Assurance = &setupAssurance{} + w.d.Identities.Auditors = nil + w.input = bufio.NewReader(strings.NewReader("INITIALIZE REHEARSAL\n")) + var command []string + w.run = func(args []string) error { + if len(args) > 1 && args[1] == "setup" { + for n, value := range args { + if value == "--" { + command = append([]string(nil), args[n+1:]...) + return nil + } + } + return errors.New("missing tool command") + } + platform, err := machineDockerPlatform() + if err != nil { + return err + } + argv, err := dockerRoleArgs(dockerRoleOptions{role: "coordinator", image: image, platform: platform, work: w.d.Work, trust: w.d.Trust, keys: w.d.Keys}, command, os.Getuid(), os.Getgid()) + if err != nil { + return err + } + output, err := exec.Command("docker", argv...).CombinedOutput() + if err != nil { + return fmt.Errorf("%w: %s", err, output) + } + return nil + } + if err := w.initialize(); err != nil { + t.Fatal(err) + } + root := filepath.Join(w.d.Work, "ceremony", "public") + inspector := transcript.Inspector{Executable: proofBinary, CeremonyPath: filepath.Join(root, "ceremony.json"), CeremonySignaturePath: filepath.Join(root, "ceremony.sig"), CoordinatorPublicKeyPath: filepath.Join(w.d.Trust, "setup-coordinator.hex"), TranscriptRoot: root} + protocol, err := inspector.DefinitionProtocol() + if err != nil { + t.Fatal(err) + } + config := base + config.CeremonyID = protocol.Definition.CeremonyID + config.CeremonyPath = "/work/ceremony/public/ceremony.json" + config.CeremonySignature = "/work/ceremony/public/ceremony.sig" + config.CoordinatorPublicKey = "/trust/setup-coordinator.hex" + config.CeremonyBinary = "/usr/local/bin/mpc-ceremony" + storagePath := filepath.Join(w.d.Work, "ceremony", "config", "relay-storage.json") + if err := writeJSONNoReplace(storagePath, config, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", credentialsPath) + platform, err := machineDockerPlatform() + if err != nil { + t.Fatal(err) + } + commitCommand := []string{"relay", "coordinator", "commit-v4", + "--storage", "/work/ceremony/config/relay-storage.json", + "--artifact-root", "/work/ceremony/public", + "--checkpoint", "/work/ceremony/public/checkpoints/initial/checkpoint.json", + "--checkpoint-signature", "/work/ceremony/public/checkpoints/initial/checkpoint.sig", + "--ceremony", config.CeremonyPath, + "--ceremony-signature", config.CeremonySignature, + "--coordinator-key", config.CoordinatorPublicKey, + "--ceremony-binary", config.CeremonyBinary, + } + onlineArgs, err := dockerRoleArgs(dockerRoleOptions{role: "coordinator", image: onlineImage, platform: platform, work: w.d.Work, trust: w.d.Trust, keys: w.d.Keys, credentials: credentialsPath}, commitCommand, os.Getuid(), os.Getgid()) + if err != nil { + t.Fatal(err) + } + output, err := exec.Command("docker", onlineArgs...).CombinedOutput() + if err != nil { + t.Fatalf("commit initial live state: %v: %s", err, output) + } + highWater, err := state.OpenWorkspaceHighWater(filepath.Join(w.d.Work, "live-sync"), protocol.Definition.CeremonyID) + if err != nil { + t.Fatal(err) + } + // The live check downloads checkpoint history into a fresh directory under + // the role work root. Mount that work root read-only so the Docker proof + // verifier can authenticate both the trusted definition and downloaded + // state without exposing keys or credentials. + driver := &dockerDriver{image: image, platform: platform, ceremonyBinary: "/usr/local/bin/mpc-ceremony", root: w.d.Work, definition: inspector.CeremonyPath, definitionSig: inspector.CeremonySignaturePath, coordinatorKey: inspector.CoordinatorPublicKeyPath, client: osDockerCommandClient{binary: "docker"}} + snapshot, err := storagefirst.SyncV4(store.Client{PublicBaseURL: config.PublishedBaseURL}, driver.inspector(), highWater, protocol.Definition.CeremonyID, w.d.Work) + if err != nil { + t.Fatal(err) + } + checkpoint, err := snapshot.State() + if err != nil { + t.Fatal(err) + } + 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) +} + +// TestV4LiveReleaseR2 is an explicit live-provider check for the last private +// handoff. It validates a retained V4 release grant against the authenticated +// frozen review, uploads a complete signed package, downloads it through the +// coordinator's inbox access, and authenticates the downloaded package with +// proof-tool. It never publishes the package. +func TestV4LiveReleaseR2(t *testing.T) { + configPath := os.Getenv("RELAY_V4_LIVE_R2_CONFIG") + credentialsPath := os.Getenv("RELAY_V4_LIVE_R2_CREDENTIALS") + proofBinary := os.Getenv("RELAY_V4_LIVE_PROOF_BINARY") + ceremonyRoot := os.Getenv("RELAY_V4_LIVE_CEREMONY_ROOT") + coordinatorKey := os.Getenv("RELAY_V4_LIVE_COORDINATOR_KEY") + packageDir := os.Getenv("RELAY_V4_LIVE_RELEASE_PACKAGE") + grantPath := os.Getenv("RELAY_V4_LIVE_RELEASE_GRANT") + if configPath == "" || credentialsPath == "" || proofBinary == "" || ceremonyRoot == "" || coordinatorKey == "" || packageDir == "" || grantPath == "" { + t.Skip("set the RELAY_V4_LIVE_R2_CONFIG, RELAY_V4_LIVE_R2_CREDENTIALS, RELAY_V4_LIVE_PROOF_BINARY, RELAY_V4_LIVE_CEREMONY_ROOT, RELAY_V4_LIVE_COORDINATOR_KEY, RELAY_V4_LIVE_RELEASE_PACKAGE and RELAY_V4_LIVE_RELEASE_GRANT paths") + } + for _, path := range []string{configPath, credentialsPath, proofBinary, ceremonyRoot, coordinatorKey, packageDir, grantPath} { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + t.Fatal("live test paths must be absolute and clean") + } + } + config, err := loadStorageConfig(configPath) + if err != nil || config.Provider != "r2" { + t.Fatalf("live test requires an existing valid R2 configuration: %v", err) + } + inspector := transcript.Inspector{ + Executable: proofBinary, + CeremonyPath: filepath.Join(ceremonyRoot, "ceremony.json"), + CeremonySignaturePath: filepath.Join(ceremonyRoot, "ceremony.sig"), + CoordinatorPublicKeyPath: coordinatorKey, + TranscriptRoot: ceremonyRoot, + } + protocol, err := inspector.DefinitionProtocol() + if err != nil { + t.Fatal(err) + } + if protocol.Definition.CeremonyID != config.CeremonyID { + t.Fatal("live R2 configuration belongs to another ceremony") + } + releaseSigner, err := workflowV4ReleaseSignerAssignment(protocol) + if err != nil { + t.Fatal(err) + } + workspace := t.TempDir() + if err := os.Chmod(workspace, 0o700); err != nil { + t.Fatal(err) + } + highWater, err := state.OpenWorkspaceHighWater(filepath.Join(workspace, "high-water"), protocol.Definition.CeremonyID) + if err != nil { + t.Fatal(err) + } + snapshot, err := storagefirst.SyncV4(store.Client{PublicBaseURL: config.PublishedBaseURL}, inspector, highWater, protocol.Definition.CeremonyID, workspace) + if err != nil { + t.Fatal(err) + } + grant, err := loadStorageFirstGrant(grantPath) + if err != nil { + t.Fatal(err) + } + destination := storagefirst.GrantDestination{Provider: config.Provider, Endpoint: config.Endpoint, Region: config.Region, InboxBucket: config.InboxBucket} + if err := storagefirst.ValidateReleaseGrantV4At(snapshot, protocol, releaseSigner.Identity.ID, grant, destination, time.Now().UTC()); err != nil { + t.Fatal(err) + } + inventory, sources, paths, err := workflowV4ReleaseFiles(packageDir) + if err != nil { + t.Fatal(err) + } + scope := storagefirst.DeliveryScope{CeremonyID: grant.CeremonyID, AttemptID: grant.AttemptID, Kind: access.SubmissionKindRelease} + uploadTemp := filepath.Join(workspace, "upload") + if err := os.Mkdir(uploadTemp, 0o700); err != nil { + t.Fatal(err) + } + if err := storagefirst.UploadDelivery(storageFirstGrantClient(grant), scope, inventory, sources, paths, uploadTemp); err != nil { + t.Fatal(err) + } + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", credentialsPath) + downloaded := filepath.Join(workspace, "downloaded-release") + if err := runCoordinatorFetchReleaseV4([]string{"--storage", configPath, "--attempt-id", grant.AttemptID, "--out-dir", downloaded}); err != nil { + t.Fatal(err) + } + command := exec.Command(proofBinary, "release", "verify", + "--ceremony", inspector.CeremonyPath, + "--ceremony-signature", inspector.CeremonySignaturePath, + "--coordinator-public-key-file", coordinatorKey, + "--keys-dir", downloaded, + "--manifest-public-key-file", filepath.Join(downloaded, "manifest-public-key.hex"), + "--signature-key-id", releaseSigner.Identity.KeyID, + ) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("authenticate downloaded release package: %v: %s", err, output) + } + t.Logf("uploaded, downloaded and authenticated release package for ceremony %s", protocol.Definition.CeremonyID) +} + +// TestV4LiveFinalizeReleaseR2 records the proof-authenticated package as the +// final signed checkpoint, publishes that checkpoint and all newly referenced +// public files, then proves a fresh client can reconstruct the terminal state. +func TestV4LiveFinalizeReleaseR2(t *testing.T) { + configPath := os.Getenv("RELAY_V4_LIVE_R2_CONFIG") + credentialsPath := os.Getenv("RELAY_V4_LIVE_R2_CREDENTIALS") + proofBinary := os.Getenv("RELAY_V4_LIVE_PROOF_BINARY") + ceremonyRoot := os.Getenv("RELAY_V4_LIVE_CEREMONY_ROOT") + coordinatorKey := os.Getenv("RELAY_V4_LIVE_COORDINATOR_KEY") + coordinatorSigningKey := os.Getenv("RELAY_V4_LIVE_COORDINATOR_SIGNING_KEY") + packageDir := os.Getenv("RELAY_V4_LIVE_RELEASE_PACKAGE") + if configPath == "" || credentialsPath == "" || proofBinary == "" || ceremonyRoot == "" || coordinatorKey == "" || coordinatorSigningKey == "" || packageDir == "" { + t.Skip("set the V4 live R2, proof, ceremony, coordinator and release-package paths") + } + for _, path := range []string{configPath, credentialsPath, proofBinary, ceremonyRoot, coordinatorKey, coordinatorSigningKey, packageDir} { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + t.Fatal("live test paths must be absolute and clean") + } + } + config, err := loadStorageConfig(configPath) + if err != nil || config.Provider != "r2" { + t.Fatalf("live test requires an existing valid R2 configuration: %v", err) + } + inspector := transcript.Inspector{Executable: proofBinary, CeremonyPath: filepath.Join(ceremonyRoot, "ceremony.json"), CeremonySignaturePath: filepath.Join(ceremonyRoot, "ceremony.sig"), CoordinatorPublicKeyPath: coordinatorKey, TranscriptRoot: ceremonyRoot} + protocol, err := inspector.DefinitionProtocol() + if err != nil { + t.Fatal(err) + } + workspace := t.TempDir() + if err := os.Chmod(workspace, 0o700); err != nil { + t.Fatal(err) + } + sync := func(name string) storagefirst.SnapshotV4 { + t.Helper() + highWater, err := state.OpenWorkspaceHighWater(filepath.Join(workspace, name), protocol.Definition.CeremonyID) + if err != nil { + t.Fatal(err) + } + snapshot, err := storagefirst.SyncV4(store.Client{PublicBaseURL: config.PublishedBaseURL}, inspector, highWater, protocol.Definition.CeremonyID, workspace) + if err != nil { + t.Fatal(err) + } + return snapshot + } + snapshot := sync("before") + stateView, err := snapshot.State() + if err != nil { + t.Fatal(err) + } + if stateView.Progress.FinalRelease != nil { + t.Logf("fresh client authenticated existing final release at sequence %d", stateView.Sequence) + return + } + if stateView.Progress.ReleaseReview == nil { + t.Fatal("live ceremony has no frozen release review") + } + releaseSigner, err := workflowV4ReleaseSignerAssignment(protocol) + if err != nil { + t.Fatal(err) + } + verify := exec.Command(proofBinary, "release", "verify", "--ceremony", inspector.CeremonyPath, "--ceremony-signature", inspector.CeremonySignaturePath, "--coordinator-public-key-file", coordinatorKey, "--keys-dir", packageDir, "--manifest-public-key-file", filepath.Join(packageDir, "manifest-public-key.hex"), "--signature-key-id", releaseSigner.Identity.KeyID) + if output, err := verify.CombinedOutput(); err != nil { + t.Fatalf("authenticate retained release package: %v: %s", err, output) + } + _, _, sourcePaths, err := workflowV4ReleaseFiles(packageDir) + if err != nil { + t.Fatal(err) + } + releaseDir := filepath.Join(ceremonyRoot, "final", "release") + if err := os.MkdirAll(releaseDir, 0o700); err != nil { + t.Fatal(err) + } + for name, source := range sourcePaths { + target := filepath.Join(releaseDir, filepath.FromSlash(name)) + if err := copyLiveFileNewOrExact(source, target); err != nil { + t.Fatalf("stage release file %q: %v", name, err) + } + } + _, _, releasePaths, err := workflowV4ReleaseFiles(releaseDir) + if err != nil { + t.Fatal(err) + } + record, signature := releasePaths["manifest.json"], releasePaths["manifest.sig"] + evidence, err := workflowV4FinalReleaseEvidence(releasePaths) + if err != nil { + t.Fatal(err) + } + basis := strings.TrimPrefix(snapshot.Head().Record.Digest.SHA256, "sha256:")[:16] + outDir := filepath.Join(ceremonyRoot, "checkpoints", "final", "release-"+basis) + if err := os.MkdirAll(filepath.Dir(outDir), 0o700); err != nil { + t.Fatal(err) + } + args := []string{"checkpoint", "record-v4", "--ceremony", inspector.CeremonyPath, "--ceremony-signature", inspector.CeremonySignaturePath, "--coordinator-public-key-file", coordinatorKey, "--artifact-root", ceremonyRoot, "--checkpoint", filepath.Join(ceremonyRoot, filepath.FromSlash(snapshot.Head().Record.Name)), "--checkpoint-signature", filepath.Join(ceremonyRoot, filepath.FromSlash(snapshot.Head().Signature.Name)), "--transition", "final-release-recorded", "--record", record, "--record-signature", signature} + for _, path := range evidence { + args = append(args, "--evidence", path) + } + args = append(args, "--coordinator-signing-key", coordinatorSigningKey, "--out-dir", outDir) + if output, err := exec.Command(proofBinary, args...).CombinedOutput(); err != nil { + t.Fatalf("record final release checkpoint: %v: %s", err, output) + } + config.CeremonyPath = inspector.CeremonyPath + config.CeremonySignature = inspector.CeremonySignaturePath + config.CoordinatorPublicKey = coordinatorKey + config.CeremonyBinary = proofBinary + hostConfig := filepath.Join(workspace, "storage.json") + if err := writeJSONNoReplace(hostConfig, config, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", credentialsPath) + if err := runCoordinatorCommitV4([]string{"--storage", hostConfig, "--artifact-root", ceremonyRoot, "--checkpoint", filepath.Join(outDir, "checkpoint.json"), "--checkpoint-signature", filepath.Join(outDir, "checkpoint.sig"), "--ceremony", inspector.CeremonyPath, "--ceremony-signature", inspector.CeremonySignaturePath, "--coordinator-key", coordinatorKey, "--ceremony-binary", proofBinary}); err != nil { + t.Fatal(err) + } + completed := sync("after") + completedState, err := completed.State() + if err != nil { + t.Fatal(err) + } + if completedState.Sequence != stateView.Sequence+1 || completedState.Progress.FinalRelease == nil || completedState.Transition.Kind != "final-release-recorded" { + t.Fatalf("fresh client did not reconstruct terminal release state: sequence=%d transition=%s", completedState.Sequence, completedState.Transition.Kind) + } + t.Logf("fresh client authenticated terminal release checkpoint at sequence %d", completedState.Sequence) +} + +func copyLiveFileNewOrExact(source, target string) error { + if existing, err := workflowV4LocalRef(filepath.Base(target), target); err == nil { + want, err := workflowV4LocalRef(filepath.Base(source), source) + if err != nil { + return err + } + if existing.Digest != want.Digest { + return errors.New("existing destination differs") + } + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + in, err := os.Open(source) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil || closeErr != nil { + _ = os.Remove(target) + return errors.Join(copyErr, closeErr) + } + return nil +} diff --git a/cmd/relay/workflow_v4_participant_guide.go b/cmd/relay/workflow_v4_participant_guide.go new file mode 100644 index 0000000..c84bce3 --- /dev/null +++ b/cmd/relay/workflow_v4_participant_guide.go @@ -0,0 +1,267 @@ +package main + +import ( + "errors" + "fmt" + "path/filepath" + "time" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +type workflowV4ParticipantProgress struct { + Local storagefirst.LocalTurnV4 + Contribution *workflowV4Operation + Cleanup *workflowV4Operation + Upload *workflowV4Operation +} + +func (j *workflowV4Journal) participantProgressV4(scope transcript.ContributionScopeV4, dockerCLI string) (workflowV4ParticipantProgress, error) { + var progress workflowV4ParticipantProgress + progress.Local.Scope = scope + for n := range j.state.Operations { + op := &j.state.Operations[n] + if op.Plan.Scope != scope { + continue + } + switch op.Plan.Kind { + case "contribute": + if op.Status == "reconciled" { + progress.Contribution = op + } + case "attest-erasure": + if op.Status == "reconciled" { + progress.Cleanup = op + } + case "upload-candidate": + if op.Status == "reconciled" { + progress.Upload = op + } + } + } + pending, err := j.pending() + if err != nil { + return progress, err + } + if pending != nil { + progress.Local.PendingOperation = true + } + if progress.Contribution != nil { + scopeFile := workflowV4ScopePath(j.state.Marker.Binding.Work, progress.Contribution.Plan.AttemptID) + id := progress.Contribution.Plan.ID + if progress.Cleanup != nil { + id = progress.Cleanup.Plan.ID + } + facts, err := j.reconcileCandidateOperation(id, scopeFile, dockerCLI) + if err != nil { + return progress, fmt.Errorf("recheck retained candidate: %w", err) + } + progress.Local = facts + progress.Local.PendingOperation = pending != nil + } + if progress.Upload != nil { + var record workflowV4UploadRecord + if err := readWorkflowV4JSON(progress.Upload.Plan.Outputs[0], &record); err != nil { + return progress, err + } + if record.Schema != workflowV4UploadRecordSchema || record.Scope != scope || record.AttemptID != progress.Upload.Plan.AttemptID || record.CandidateResultID == "" { + return progress, errors.New("retained upload result differs from this participant turn") + } + progress.Local.UploadedAttemptID = record.AttemptID + progress.Local.UploadedArtifactID = record.CandidateResultID + } + return progress, nil +} + +func workflowV4ScopePath(work, attempt string) string { + return filepath.Join(work, "workflow-v4", "scopes", attempt+".json") +} + +func workflowV4ParticipantActionLabel(recommendation storagefirst.TurnRecommendationV4, pending *workflowV4Operation) string { + if pending != nil { + switch pending.Plan.Kind { + case "contribute": + if pending.Status == "prepared" { + return "Run the prepared contribution" + } + return "Inspect the retained contribution result" + case "attest-erasure": + if pending.Status == "prepared" { + return "Create the prepared cleanup statement" + } + return "Inspect the retained cleanup result" + case "upload-candidate": + if pending.Status == "running" { + return "Safely continue the exact candidate upload" + } + return "Verify the exact candidate upload" + } + return "Inspect the retained operation" + } + switch recommendation.Action { + case "submit-your-enrollment": + return "Upload your signed enrollment" + case "contribute": + return "Verify the signed allocation and contribute" + case "confirm-cleanup-and-sign-attestation": + return "Confirm cleanup and sign the cleanup statement" + case "get-candidate-grant", "upload-candidate": + return "Select your private upload grant and upload the candidate" + } + return "" +} + +func runWorkflowV4ParticipantAction(ui *coordinatorWizard, j *workflowV4Journal, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, participant access.RoleConfig, config access.StorageConfig, inspector transcript.Inspector, dockerCLI string, view storagefirst.TurnViewV4, progress workflowV4ParticipantProgress) error { + pending, err := j.pending() + if err != nil { + return err + } + if pending != nil { + switch pending.Plan.Kind { + case "contribute": + if pending.Status == "prepared" { + if err := ui.confirm("Run the exact prepared contribution in the isolated container", "CONTRIBUTE"); err != nil { + return err + } + if err := j.executePreparedContribution(pending.Plan.ID, dockerCLI, snapshot, protocol, progress.Local); err != nil { + return err + } + } + _, err := j.reconcileCandidateOperation(pending.Plan.ID, workflowV4ScopePath(j.state.Marker.Binding.Work, pending.Plan.AttemptID), dockerCLI) + return err + case "attest-erasure": + if pending.Status == "prepared" { + if err := ui.confirm("Sign the exact prepared cleanup statement", "SIGN CLEANUP"); err != nil { + return err + } + if err := j.executePreparedErasure(pending.Plan.ID, workflowV4ScopePath(j.state.Marker.Binding.Work, pending.Plan.AttemptID), dockerCLI); err != nil { + return err + } + } + _, err := j.reconcileCandidateOperation(pending.Plan.ID, workflowV4ScopePath(j.state.Marker.Binding.Work, pending.Plan.AttemptID), dockerCLI) + return err + case "upload-candidate": + return runWorkflowV4ParticipantUpload(ui, j, snapshot, protocol, config, dockerCLI, view, progress, pending) + default: + return errors.New("retained operation needs a role-specific verifier before Relay can continue") + } + } + recommendation, err := snapshot.RecommendTurnV4(protocol, participant.Phase, storagefirst.Participant, participant.IdentityID, progress.Local, "", time.Now().UTC()) + if err != nil { + return err + } + switch recommendation.Action { + case "submit-your-enrollment": + return runWorkflowV4ParticipantEnrollment(ui, snapshot, protocol, participant, config, inspector) + case "contribute": + if err := ui.confirm("Proof-tool will verify this exact allocation and input snapshot before generating randomness", "CONTRIBUTE"); err != nil { + return err + } + plan, scopeFile, err := prepareWorkflowV4Contribution(snapshot, protocol, j.state.Marker.Binding, participant, time.Now().UTC()) + if err != nil { + return err + } + if err := j.prepare(plan); err != nil { + return err + } + if err := j.executePreparedContribution(plan.ID, dockerCLI, snapshot, protocol, storagefirst.LocalTurnV4{Scope: plan.Scope}); err != nil { + return err + } + _, err = j.reconcileCandidateOperation(plan.ID, scopeFile, dockerCLI) + return err + case "confirm-cleanup-and-sign-attestation": + if progress.Contribution == nil || progress.Local.GeneratedOutput == nil { + return errors.New("verified retained computation required before cleanup") + } + candidate := progress.Contribution.Plan.Outputs[0] + driver := &dockerDriver{image: progress.Contribution.Plan.Runtime.Image, platform: progress.Contribution.Plan.Runtime.Platform} + role := roleOpts{outDir: candidate, docker: driver} + if err := confirmDockerNoCopiesWithIO(role, ui.input, ui.output); err != nil { + return err + } + destroyedAt := erasureTimestamp(candidate, time.Now().UTC()) + if err := persistDockerErasureIntent(role, destroyedAt); err != nil { + return err + } + plan, err := j.prepareWorkflowV4Erasure(progress.Contribution.Plan.ID, workflowV4ScopePath(j.state.Marker.Binding.Work, progress.Contribution.Plan.AttemptID), *progress.Local.GeneratedOutput, destroyedAt) + if err != nil { + return err + } + if err := j.prepare(plan); err != nil { + return err + } + if err := j.executePreparedErasure(plan.ID, workflowV4ScopePath(j.state.Marker.Binding.Work, progress.Contribution.Plan.AttemptID), dockerCLI); err != nil { + return err + } + _, err = j.reconcileCandidateOperation(plan.ID, workflowV4ScopePath(j.state.Marker.Binding.Work, progress.Contribution.Plan.AttemptID), dockerCLI) + return err + case "get-candidate-grant", "upload-candidate": + return runWorkflowV4ParticipantUpload(ui, j, snapshot, protocol, config, dockerCLI, view, progress, nil) + default: + return fmt.Errorf("current signed state does not authorize a participant action: %s", recommendation.Reason) + } +} + +func runWorkflowV4ParticipantUpload(ui *coordinatorWizard, j *workflowV4Journal, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, config access.StorageConfig, dockerCLI string, view storagefirst.TurnViewV4, progress workflowV4ParticipantProgress, pending *workflowV4Operation) error { + _ = dockerCLI // retained for the common action signature; upload does not invoke Docker. + if progress.Cleanup == nil || progress.Local.CandidateInventory == nil || view.CandidateAttempt == nil { + return errors.New("verified five-file candidate and active upload attempt required") + } + path, err := ui.required("Absolute path to the private candidate upload grant received from the coordinator or Tessera", "") + if err != nil { + return err + } + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return errors.New("private grant path must be absolute and clean") + } + grant, err := loadStorageFirstGrant(path) + if err != nil { + return err + } + destination := storagefirst.GrantDestination{Provider: config.Provider, Endpoint: config.Endpoint, Region: config.Region, InboxBucket: config.InboxBucket} + if err := storagefirst.ValidateGrantV4At(snapshot, protocol, j.state.Marker.Binding.IdentityID, grant, destination, time.Now().UTC()); err != nil { + return err + } + if pending == nil { + plan, err := j.prepareWorkflowV4Upload(progress.Cleanup.Plan.ID, view.CandidateAttempt.AttemptID, *progress.Local.CandidateInventory) + if err != nil { + return err + } + if err := ui.confirm("Upload only the verified five public candidate files; the manifest will be last", "UPLOAD CANDIDATE"); err != nil { + return err + } + if err := j.prepare(plan); err != nil { + return err + } + pending, err = j.pending() + if err != nil { + return err + } + } + if pending.Plan.AttemptID != view.CandidateAttempt.AttemptID { + return errors.New("retained upload belongs to a retired attempt; preserve it and make a fresh contribution for the replacement allocation") + } + switch pending.Status { + case "prepared": + if err := j.executePreparedCandidateUpload(pending.Plan.ID, snapshot, protocol, grant, destination, *progress.Local.CandidateInventory, time.Now().UTC()); err != nil { + return err + } + case "running": + if err := ui.confirm("Continue the exact immutable upload; existing bytes will be compared and never replaced", "CONTINUE UPLOAD"); err != nil { + return err + } + if err := j.resumeCandidateUpload(pending.Plan.ID, snapshot, protocol, grant, destination, *progress.Local.CandidateInventory, time.Now().UTC()); err != nil { + return err + } + case "returned-needs-verification": + default: + return errors.New("candidate upload is not in a resumable state") + } + return j.reconcileCandidateUpload(pending.Plan.ID, storageFirstGrantClient(grant), *progress.Local.CandidateInventory) +} + +func workflowV4ParticipantRecommendation(snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, participant access.RoleConfig, progress workflowV4ParticipantProgress, now time.Time) (storagefirst.TurnRecommendationV4, error) { + return snapshot.RecommendTurnV4(protocol, participant.Phase, storagefirst.Participant, participant.IdentityID, progress.Local, "", now) +} diff --git a/cmd/relay/workflow_v4_phase_lifecycle.go b/cmd/relay/workflow_v4_phase_lifecycle.go new file mode 100644 index 0000000..3c04bef --- /dev/null +++ b/cmd/relay/workflow_v4_phase_lifecycle.go @@ -0,0 +1,822 @@ +package main + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +const ( + workflowV4ClosePhase1 = "close-phase1" + workflowV4ClosePhase2 = "close-phase2" + workflowV4BeaconPhase1 = "record-phase1-beacon" + workflowV4BeaconPhase2 = "record-phase2-beacon" + workflowV4SealPhase1 = "seal-phase1" + workflowV4StartPhase2 = "start-phase2" + workflowV4Finalize = "finalize-candidate" + workflowV4Review = "freeze-release-review" + workflowV4Release = "complete-signed-release" +) + +const workflowV4QuicknetChainHash = "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971" + +const ( + workflowV4ProtocolLabsDrand = "https://api.drand.sh" + workflowV4CloudflareDrand = "https://drand.cloudflare.com" +) + +// workflowV4CoordinatorLifecycleAction selects ceremony-wide work only when no +// participant turn remains open. It does not infer completion from local files. +func workflowV4CoordinatorLifecycleAction(state transcript.CheckpointStateV4, _ transcript.CheckpointCommitmentsV4, protocol transcript.DefinitionProtocol) (string, string, error) { + phase1, err := protocol.Definition.Schedule("phase1") + if err != nil { + return "", "", err + } + if int(state.Progress.Phase1.AcceptedCount) == len(phase1) && state.Progress.Phase1Closure == nil { + return workflowV4ClosePhase1, "Replay every accepted Phase 1 contribution and commit the signed closure", nil + } + if state.Progress.Phase1Closure != nil && state.Progress.Phase1Beacon == nil { + return workflowV4BeaconPhase1, "Fetch and verify the exact future beacon round committed by the Phase 1 closure", nil + } + if state.Progress.Phase1Beacon != nil && state.Progress.Phase1Seal == nil { + return workflowV4SealPhase1, "Apply the authenticated Phase 1 beacon and commit the sealed Phase 1 transcript", nil + } + if state.Progress.Phase1Seal != nil && state.Progress.Phase2 == nil { + return workflowV4StartPhase2, "Derive and commit the Phase 2 starting state from sealed Phase 1", nil + } + if state.Progress.Phase2 != nil { + phase2, err := protocol.Definition.Schedule("phase2") + if err != nil { + return "", "", err + } + if int(state.Progress.Phase2.AcceptedCount) == len(phase2) && state.Progress.Phase2Closure == nil { + return workflowV4ClosePhase2, "Replay every accepted Phase 2 contribution and commit the signed closure", nil + } + if state.Progress.Phase2Closure != nil && state.Progress.Phase2Beacon == nil { + return workflowV4BeaconPhase2, "Fetch and verify the exact future beacon round committed by the Phase 2 closure", nil + } + if state.Progress.Phase2Beacon != nil && state.Progress.FinalCandidate == nil { + return workflowV4Finalize, "Replay both completed phases and prepare the coordinator-signed final candidate", nil + } + if state.Progress.FinalCandidate != nil && state.Progress.ReleaseReview == nil { + return workflowV4Review, "Assemble and sign the exact operational evidence bundle for release review", nil + } + if state.Progress.ReleaseReview != nil && state.Progress.FinalRelease == nil { + return workflowV4Release, "Issue the release signer upload grant or verify and record the returned signed package", nil + } + } + return "", "", nil +} + +func runWorkflowV4CoordinatorLifecycle(ui *coordinatorWizard, action string, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, online, signer guidedProfile, inspector transcript.Inspector) error { + if action == workflowV4BeaconPhase1 || action == workflowV4BeaconPhase2 { + phase := "phase1" + if action == workflowV4BeaconPhase2 { + phase = "phase2" + } + return runWorkflowV4BeaconLifecycle(ui, phase, snapshot, online, signer, inspector) + } + if action == workflowV4SealPhase1 { + return runWorkflowV4SealPhase1Lifecycle(ui, snapshot, online, signer) + } + if action == workflowV4StartPhase2 { + return runWorkflowV4StartPhase2Lifecycle(ui, snapshot, online, signer) + } + if action == workflowV4Finalize { + return runWorkflowV4FinalizeLifecycle(ui, snapshot, protocol, online, signer) + } + if action == workflowV4Review { + return runWorkflowV4ReleaseReviewLifecycle(ui, snapshot, online, signer) + } + if action == workflowV4Release { + return runWorkflowV4FinalReleaseLifecycle(ui, snapshot, protocol, online, signer) + } + phase := "phase1" + if action == workflowV4ClosePhase2 { + phase = "phase2" + } else if action != workflowV4ClosePhase1 { + return errors.New("unsupported V4 lifecycle action") + } + state, err := snapshot.State() + if err != nil { + return err + } + phaseState := state.Progress.Phase1 + if phase == "phase2" { + if state.Progress.Phase2 == nil || state.Progress.Phase1Seal == nil { + return errors.New("authenticated Phase 2 state and Phase 1 seal are required") + } + phaseState = *state.Progress.Phase2 + } + schedule, err := protocol.Definition.Schedule(phase) + if err != nil { + return err + } + if int(phaseState.AcceptedCount) != len(schedule) { + return errors.New("phase cannot close before all scheduled contributions are accepted") + } + journey, err := protocol.Definition.RequireJourney() + if err != nil { + return err + } + if journey.BeaconRoundLeadSeconds == 0 { + return errors.New("signed ceremony definition has no beacon lead time") + } + if err := ui.confirm(fmt.Sprintf("Replay all %s contributions and choose the signed future beacon round at least %d seconds ahead", phase, journey.BeaconRoundLeadSeconds), "CLOSE "+strings.ToUpper(phase)); err != nil { + return err + } + root := filepath.Join(online.Work, "ceremony", "public") + closureDir := filepath.Join(root, phase, "closure") + closureRecord := filepath.Join(closureDir, "record.json") + closureSignature := filepath.Join(closureDir, "record.sig") + if record, signature := regularPreparationFile(closureRecord), regularPreparationFile(closureSignature); record != signature { + return errors.New("incomplete retained phase closure; preserve it for inspection") + } else if !record { + command, err := workflowV4CloseCommand(state, online, signer, phase, phaseState, journey.BeaconRoundLeadSeconds) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } + basis := strings.TrimPrefix(snapshot.Head().Record.Digest.SHA256, "sha256:")[:16] + outputDir := filepath.Join(root, "checkpoints", phase, "lifecycle", "closed-"+basis) + if err := prepareWorkflowV4FreshOutputParent(root, outputDir); err != nil { + return err + } + if _, err := os.Lstat(filepath.Join(outputDir, "checkpoint.json")); errors.Is(err, os.ErrNotExist) { + command, err := workflowV4RecordCommand(snapshot, online, signer, phase+"-closed", closureRecord, closureSignature, nil, outputDir) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } else if err != nil { + return err + } + return runWorkflowV4CommitCommand(online, outputDir) +} + +func runWorkflowV4ReleaseReviewLifecycle(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, online, signer guidedProfile) error { + state, err := snapshot.State() + if err != nil { + return err + } + if state.Progress.FinalCandidate == nil || state.Progress.ReleaseReview != nil || state.Progress.FinalRelease != nil { + return errors.New("release review requires one authenticated final candidate and no existing review or release") + } + root := filepath.Join(online.Work, "ceremony", "public") + operational := filepath.Join(root, "operational") + if err := os.MkdirAll(operational, 0o700); err != nil { + return err + } + bundle := filepath.Join(operational, "evidence-bundle.json") + signature := filepath.Join(operational, "evidence-bundle.sig") + if !regularPreparationFile(bundle) { + if regularPreparationFile(signature) { + return errors.New("retained evidence signature has no canonical bundle; preserve it for inspection") + } + command, err := workflowV4BundleCommand(snapshot.Head(), online, signer, "prepare", bundle, signature, "", time.Now().UTC()) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } + raw, err := os.ReadFile(bundle) + if err != nil { + return err + } + digest := fmt.Sprintf("%x", sha256.Sum256(raw)) + if !regularPreparationFile(signature) { + if err := ui.confirm("Review and freeze the exact coordinator evidence bundle (SHA-256 "+digest+"). Later evidence cannot be added to this release.", "SIGN EVIDENCE BUNDLE"); err != nil { + return err + } + command, err := workflowV4BundleCommand(snapshot.Head(), online, signer, "sign", bundle, signature, digest, time.Time{}) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } + basis := strings.TrimPrefix(snapshot.Head().Record.Digest.SHA256, "sha256:")[:16] + outputDir := filepath.Join(root, "checkpoints", "final", "review-"+basis) + if err := prepareWorkflowV4FreshOutputParent(root, outputDir); err != nil { + return err + } + if _, err := os.Lstat(filepath.Join(outputDir, "checkpoint.json")); errors.Is(err, os.ErrNotExist) { + command, err := workflowV4RecordCommand(snapshot, online, signer, "release-review-recorded", bundle, signature, nil, outputDir) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } else if err != nil { + return err + } + return runWorkflowV4CommitCommand(online, outputDir) +} + +func workflowV4BundleCommand(head transcript.SignedArtifactRefs, online, signer guidedProfile, action, bundle, signature, reviewedSHA string, assembledAt time.Time) ([]string, error) { + root := filepath.Join(online.Work, "ceremony", "public") + mapWork := func(path string) (string, error) { return pathWithin(signer.Work, path, "/work") } + ceremony, err := mapWork(filepath.Join(root, "ceremony.json")) + if err != nil { + return nil, err + } + ceremonySignature, _ := mapWork(filepath.Join(root, "ceremony.sig")) + artifactRoot, _ := mapWork(root) + checkpoint, err := mapWork(filepath.Join(root, filepath.FromSlash(head.Record.Name))) + if err != nil { + return nil, err + } + checkpointSignature, _ := mapWork(filepath.Join(root, filepath.FromSlash(head.Signature.Name))) + bundlePath, _ := mapWork(bundle) + signaturePath, _ := mapWork(signature) + coordinatorKey, err := pathWithin(signer.Trust, filepath.Join(online.Trust, "setup-coordinator.hex"), "/trust") + if err != nil { + return nil, err + } + command := []string{"mpc-ceremony", "ops", "prepare-bundle-v4", "--ceremony", ceremony, "--ceremony-signature", ceremonySignature, "--coordinator-public-key-file", coordinatorKey, "--artifact-root", artifactRoot, "--checkpoint", checkpoint, "--checkpoint-signature", checkpointSignature} + switch action { + case "prepare": + return append(command, "--assembled-at", assembledAt.UTC().Format(time.RFC3339Nano), "--out", bundlePath), nil + case "sign": + command[2] = "sign-bundle-v4" + return append(command, "--operational-bundle", bundlePath, "--coordinator-signing-key", "/keys/signing.hex", "--reviewed", "--reviewed-sha256", reviewedSHA, "--out", signaturePath), nil + default: + return nil, errors.New("unsupported evidence-bundle action") + } +} + +func runWorkflowV4FinalizeLifecycle(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, online, signer guidedProfile) error { + state, err := snapshot.State() + if err != nil { + return err + } + if state.Progress.Phase1Closure == nil || state.Progress.Phase1Beacon == nil || state.Progress.Phase1Seal == nil || state.Progress.Phase2 == nil || state.Progress.Phase2Closure == nil || state.Progress.Phase2Beacon == nil || state.Progress.FinalCandidate != nil { + return errors.New("final candidate requires both authenticated completed phases and no existing candidate") + } + if err := ui.confirm("Replay both phases, verify the public proof evidence, and create the exact coordinator-signed candidate", "FINALIZE CANDIDATE"); err != nil { + return err + } + root := filepath.Join(online.Work, "ceremony", "public") + finalRoot := filepath.Join(root, "final") + if err := os.MkdirAll(finalRoot, 0o700); err != nil { + return err + } + preliminary := filepath.Join(finalRoot, "preliminary") + if _, err := os.Lstat(preliminary); errors.Is(err, os.ErrNotExist) { + command, err := workflowV4FinalizeCommand(state, online, signer, "prepare", preliminary, "", time.Now().UTC()) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } else if err != nil { + return err + } + publicEvidence := filepath.Join(finalRoot, "public-finalization-evidence.json") + if !regularPreparationFile(publicEvidence) { + if protocol.Definition.Mode != "rehearsal" { + return fmt.Errorf("public proof evidence is required before finalization; place the reviewed public artifact at %s and retry", publicEvidence) + } + command, err := workflowV4RehearsalEvidenceCommand(state.CeremonyID, online, signer, preliminary, publicEvidence) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } + candidate := filepath.Join(finalRoot, "candidate") + if _, err := os.Lstat(candidate); errors.Is(err, os.ErrNotExist) { + command, err := workflowV4FinalizeCommand(state, online, signer, "complete", candidate, publicEvidence, time.Now().UTC()) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } else if err != nil { + return err + } + record := filepath.Join(candidate, "candidate.json") + signature := filepath.Join(candidate, "candidate.sig.json") + evidence := workflowV4FinalCandidateEvidence(candidate) + for _, path := range append([]string{record, signature}, evidence...) { + if !regularPreparationFile(path) { + return errors.New("final candidate output is incomplete; preserve it for inspection") + } + } + basis := strings.TrimPrefix(snapshot.Head().Record.Digest.SHA256, "sha256:")[:16] + outputDir := filepath.Join(root, "checkpoints", "final", "candidate-"+basis) + if err := prepareWorkflowV4FreshOutputParent(root, outputDir); err != nil { + return err + } + if _, err := os.Lstat(filepath.Join(outputDir, "checkpoint.json")); errors.Is(err, os.ErrNotExist) { + command, err := workflowV4RecordCommand(snapshot, online, signer, "final-candidate-recorded", record, signature, evidence, outputDir) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } else if err != nil { + return err + } + return runWorkflowV4CommitCommand(online, outputDir) +} + +func workflowV4FinalCandidateEvidence(candidate string) []string { + names := []string{ + "ownership-destination.ccs", "ownership.pk", "ownership.vk", + "cardano-vk.bin", "cardano-vk.hex", "cardano-vk-format.txt", + "verification-report.json", "candidate-checksums.sha256", + "public-finalization-evidence.json", "phase2-seal.json", "phase2-seal.sig.json", + } + evidence := make([]string, len(names)) + for index, name := range names { + evidence[index] = filepath.Join(candidate, name) + } + return evidence +} + +func workflowV4FinalizeCommand(state transcript.CheckpointStateV4, online, signer guidedProfile, action, outputDir, publicEvidence string, at time.Time) ([]string, error) { + args, err := workflowV4ReplayArgs(state, online, signer) + if err != nil { + return nil, err + } + out, err := pathWithin(signer.Work, outputDir, "/work") + if err != nil { + return nil, err + } + command := append([]string{"mpc-ceremony", "finalize", action}, args...) + command = append(command, "--coordinator-signing-key", "/keys/signing.hex") + switch action { + case "prepare": + command = append(command, "--prepared-at", at.UTC().Format(time.RFC3339Nano)) + case "complete": + evidence, err := pathWithin(signer.Work, publicEvidence, "/work") + if err != nil { + return nil, err + } + command = append(command, "--public-evidence", evidence, "--finalized-at", at.UTC().Format(time.RFC3339Nano)) + default: + return nil, errors.New("unsupported finalization action") + } + return append(command, "--out-dir", out), nil +} + +func workflowV4ReplayArgs(state transcript.CheckpointStateV4, online, signer guidedProfile) ([]string, error) { + if state.Progress.Phase1Closure == nil || state.Progress.Phase1Beacon == nil || state.Progress.Phase1Seal == nil || state.Progress.Phase2 == nil || state.Progress.Phase2Closure == nil || state.Progress.Phase2Beacon == nil { + return nil, errors.New("complete authenticated replay inputs required") + } + root := filepath.Join(online.Work, "ceremony", "public") + mapWork := func(path string) (string, error) { return pathWithin(signer.Work, path, "/work") } + mapRef := func(ref transcript.ArtifactRef) (string, error) { + return mapWork(filepath.Join(root, filepath.FromSlash(ref.Name))) + } + ceremony, err := mapWork(filepath.Join(root, "ceremony.json")) + if err != nil { + return nil, err + } + ceremonySignature, _ := mapWork(filepath.Join(root, "ceremony.sig")) + transcriptRoot, _ := mapWork(root) + coordinatorKey, err := pathWithin(signer.Trust, filepath.Join(online.Trust, "setup-coordinator.hex"), "/trust") + if err != nil { + return nil, err + } + values := []struct { + flag string + ref transcript.ArtifactRef + }{ + {"--phase1-chain", state.Progress.Phase1.Chain.Record}, {"--phase1-chain-signature", state.Progress.Phase1.Chain.Signature}, + {"--phase1-close", state.Progress.Phase1Closure.Record}, {"--phase1-close-signature", state.Progress.Phase1Closure.Signature}, + {"--phase1-beacon", state.Progress.Phase1Beacon.Record}, {"--phase1-beacon-signature", state.Progress.Phase1Beacon.Signature}, + {"--phase1-seal", state.Progress.Phase1Seal.Record}, {"--phase1-seal-signature", state.Progress.Phase1Seal.Signature}, + {"--phase2-chain", state.Progress.Phase2.Chain.Record}, {"--phase2-chain-signature", state.Progress.Phase2.Chain.Signature}, + {"--phase2-close", state.Progress.Phase2Closure.Record}, {"--phase2-close-signature", state.Progress.Phase2Closure.Signature}, + {"--phase2-beacon", state.Progress.Phase2Beacon.Record}, {"--phase2-beacon-signature", state.Progress.Phase2Beacon.Signature}, + } + args := []string{"--ceremony", ceremony, "--ceremony-signature", ceremonySignature, "--coordinator-public-key-file", coordinatorKey, "--transcript-root", transcriptRoot} + for _, value := range values { + path, err := mapRef(value.ref) + if err != nil { + return nil, err + } + args = append(args, value.flag, path) + } + return args, nil +} + +func workflowV4RehearsalEvidenceCommand(ceremonyID string, online, signer guidedProfile, preliminary, output string) ([]string, error) { + keys, err := pathWithin(signer.Work, preliminary, "/work") + if err != nil { + return nil, err + } + out, err := pathWithin(signer.Work, output, "/work") + if err != nil { + return nil, err + } + coordinatorKey, err := pathWithin(signer.Trust, filepath.Join(online.Trust, "setup-coordinator.hex"), "/trust") + if err != nil { + return nil, err + } + return []string{"mpc-ceremony", "finalize", "rehearsal-evidence", "--keys-dir", keys, "--coordinator-public-key-file", coordinatorKey, "--ceremony-id", ceremonyID, "--out", out}, nil +} + +func runWorkflowV4SealPhase1Lifecycle(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, online, signer guidedProfile) error { + state, err := snapshot.State() + if err != nil { + return err + } + if state.Progress.Phase1Closure == nil || state.Progress.Phase1Beacon == nil || state.Progress.Phase1Seal != nil { + return errors.New("Phase 1 seal requires an authenticated closure and beacon and no existing seal") + } + if err := ui.confirm("Replay sealed Phase 1 inputs and apply the exact authenticated beacon", "SEAL PHASE1"); err != nil { + return err + } + root := filepath.Join(online.Work, "ceremony", "public") + sealDir := filepath.Join(root, "phase1", "sealed") + sealRecord := filepath.Join(sealDir, "seal.json") + sealSignature := filepath.Join(sealDir, "seal.sig") + commons := filepath.Join(sealDir, "commons.bin") + complete := regularPreparationFile(sealRecord) && regularPreparationFile(sealSignature) && regularPreparationFile(commons) + if !complete { + for _, path := range []string{sealRecord, sealSignature, commons} { + if _, statErr := os.Lstat(path); statErr == nil { + return errors.New("incomplete retained Phase 1 seal; preserve it for inspection") + } else if !errors.Is(statErr, os.ErrNotExist) { + return statErr + } + } + command, err := workflowV4SealCommand(online, signer, *state.Progress.Phase1Closure, *state.Progress.Phase1Beacon, sealDir) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } + basis := strings.TrimPrefix(snapshot.Head().Record.Digest.SHA256, "sha256:")[:16] + outputDir := filepath.Join(root, "checkpoints", "phase1", "lifecycle", "sealed-"+basis) + if err := prepareWorkflowV4FreshOutputParent(root, outputDir); err != nil { + return err + } + if _, err := os.Lstat(filepath.Join(outputDir, "checkpoint.json")); errors.Is(err, os.ErrNotExist) { + command, err := workflowV4RecordCommand(snapshot, online, signer, "phase1-sealed", sealRecord, sealSignature, []string{commons}, outputDir) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } else if err != nil { + return err + } + return runWorkflowV4CommitCommand(online, outputDir) +} + +func workflowV4SealCommand(online, signer guidedProfile, closure, beacon transcript.SignedArtifactRefs, outputDir string) ([]string, error) { + root := filepath.Join(online.Work, "ceremony", "public") + mapWork := func(path string) (string, error) { return pathWithin(signer.Work, path, "/work") } + ceremony, err := mapWork(filepath.Join(root, "ceremony.json")) + if err != nil { + return nil, err + } + ceremonySignature, _ := mapWork(filepath.Join(root, "ceremony.sig")) + transcriptRoot, _ := mapWork(root) + closureRecord, _ := mapWork(filepath.Join(root, filepath.FromSlash(closure.Record.Name))) + closureSignature, _ := mapWork(filepath.Join(root, filepath.FromSlash(closure.Signature.Name))) + beaconRecord, _ := mapWork(filepath.Join(root, filepath.FromSlash(beacon.Record.Name))) + beaconSignature, _ := mapWork(filepath.Join(root, filepath.FromSlash(beacon.Signature.Name))) + out, err := mapWork(outputDir) + if err != nil { + return nil, err + } + coordinatorKey, err := pathWithin(signer.Trust, filepath.Join(online.Trust, "setup-coordinator.hex"), "/trust") + if err != nil { + return nil, err + } + return []string{"mpc-ceremony", "phase1", "seal", "--ceremony", ceremony, "--ceremony-signature", ceremonySignature, "--coordinator-public-key-file", coordinatorKey, "--transcript-dir", transcriptRoot, "--closure", closureRecord, "--closure-signature", closureSignature, "--beacon", beaconRecord, "--beacon-signature", beaconSignature, "--coordinator-signing-key", "/keys/signing.hex", "--out-dir", out}, nil +} + +func runWorkflowV4StartPhase2Lifecycle(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, online, signer guidedProfile) error { + state, err := snapshot.State() + if err != nil { + return err + } + if state.Progress.Phase1Seal == nil || state.Progress.Phase2 != nil { + return errors.New("Phase 2 initialization requires sealed Phase 1 and no existing Phase 2") + } + if err := ui.confirm("Derive the exact Phase 2 genesis and initial signed chain from sealed Phase 1", "START PHASE2"); err != nil { + return err + } + root := filepath.Join(online.Work, "ceremony", "public") + phase2Dir := filepath.Join(root, "phase2") + chain := filepath.Join(phase2Dir, "chain-0000.json") + chainSignature := filepath.Join(phase2Dir, "chain-0000.sig") + genesis := filepath.Join(phase2Dir, "genesis.bin") + complete := regularPreparationFile(chain) && regularPreparationFile(chainSignature) && regularPreparationFile(genesis) + if !complete { + if _, statErr := os.Lstat(phase2Dir); statErr == nil { + return errors.New("incomplete retained Phase 2 initialization; preserve it for inspection") + } else if !errors.Is(statErr, os.ErrNotExist) { + return statErr + } + command, err := workflowV4Phase2InitCommand(online, signer, *state.Progress.Phase1Seal, phase2Dir) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } + basis := strings.TrimPrefix(snapshot.Head().Record.Digest.SHA256, "sha256:")[:16] + outputDir := filepath.Join(root, "checkpoints", "phase2", "lifecycle", "initialized-"+basis) + if err := prepareWorkflowV4FreshOutputParent(root, outputDir); err != nil { + return err + } + if _, err := os.Lstat(filepath.Join(outputDir, "checkpoint.json")); errors.Is(err, os.ErrNotExist) { + command, err := workflowV4RecordCommand(snapshot, online, signer, "phase2-initialized", chain, chainSignature, []string{genesis}, outputDir) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } else if err != nil { + return err + } + return runWorkflowV4CommitCommand(online, outputDir) +} + +func workflowV4Phase2InitCommand(online, signer guidedProfile, seal transcript.SignedArtifactRefs, outputDir string) ([]string, error) { + root := filepath.Join(online.Work, "ceremony", "public") + mapWork := func(path string) (string, error) { return pathWithin(signer.Work, path, "/work") } + ceremony, err := mapWork(filepath.Join(root, "ceremony.json")) + if err != nil { + return nil, err + } + ceremonySignature, _ := mapWork(filepath.Join(root, "ceremony.sig")) + transcriptRoot, _ := mapWork(root) + sealRecord, _ := mapWork(filepath.Join(root, filepath.FromSlash(seal.Record.Name))) + sealSignature, _ := mapWork(filepath.Join(root, filepath.FromSlash(seal.Signature.Name))) + out, err := mapWork(outputDir) + if err != nil { + return nil, err + } + coordinatorKey, err := pathWithin(signer.Trust, filepath.Join(online.Trust, "setup-coordinator.hex"), "/trust") + if err != nil { + return nil, err + } + return []string{"mpc-ceremony", "phase2", "init", "--ceremony", ceremony, "--ceremony-signature", ceremonySignature, "--coordinator-public-key-file", coordinatorKey, "--phase1-transcript-dir", transcriptRoot, "--phase1-seal", sealRecord, "--phase1-seal-signature", sealSignature, "--coordinator-signing-key", "/keys/signing.hex", "--out-dir", out}, nil +} + +func runWorkflowV4BeaconLifecycle(ui *coordinatorWizard, phase string, snapshot storagefirst.SnapshotV4, online, signer guidedProfile, inspector transcript.Inspector) error { + state, err := snapshot.State() + if err != nil { + return err + } + var closure *transcript.SignedArtifactRefs + if phase == "phase1" { + closure = state.Progress.Phase1Closure + } else if phase == "phase2" { + closure = state.Progress.Phase2Closure + } else { + return errors.New("unknown beacon phase") + } + if closure == nil { + return errors.New("authenticated phase closure required before beacon retrieval") + } + journey, err := inspector.Journey() + if err != nil { + return fmt.Errorf("inspect signed closure timing: %w", err) + } + var phaseJourney *transcript.PhaseJourney + for n := range journey.Phases { + if journey.Phases[n].Phase == phase { + phaseJourney = &journey.Phases[n] + } + } + if phaseJourney == nil || !phaseJourney.Closed || phaseJourney.BeaconRound == 0 { + return errors.New("proof-tool did not report a complete signed closure") + } + scheduled, err := time.Parse(time.RFC3339Nano, phaseJourney.BeaconScheduledAt) + if err != nil { + return errors.New("proof-tool reported an invalid beacon schedule") + } + now := time.Now().UTC() + if now.Before(scheduled) { + return fmt.Errorf("committed beacon round is not public yet; retry after %s UTC", scheduled.UTC().Format(time.RFC3339)) + } + if err := ui.confirm(fmt.Sprintf("Download drand Quicknet round %d and let Proof-tool authenticate it against the signed closure", phaseJourney.BeaconRound), "RECORD "+strings.ToUpper(phase)+" BEACON"); err != nil { + return err + } + responseDir := filepath.Join(online.Work, "workflow-v4", "beacons") + if err := ensureWorkflowV4Directory(online.Work, responseDir); err != nil { + return err + } + responsePath := filepath.Join(responseDir, fmt.Sprintf("%s-round-%d.json", phase, phaseJourney.BeaconRound)) + if err := fetchWorkflowV4QuicknetRound(phaseJourney.BeaconRound, responsePath); err != nil { + return err + } + root := filepath.Join(online.Work, "ceremony", "public") + beaconRecord := filepath.Join(root, phase, "beacon", "record.json") + beaconSignature := filepath.Join(root, phase, "beacon", "record.sig") + if record, signature := regularPreparationFile(beaconRecord), regularPreparationFile(beaconSignature); record != signature { + return errors.New("incomplete retained beacon record; preserve it for inspection") + } else if !record { + command, err := workflowV4BeaconCommand(online, signer, phase, *closure, responsePath, now) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } + rawResponse := filepath.Join(root, phase, "beacon", "raw-response.bin") + basis := strings.TrimPrefix(snapshot.Head().Record.Digest.SHA256, "sha256:")[:16] + outputDir := filepath.Join(root, "checkpoints", phase, "lifecycle", "beacon-"+basis) + if err := prepareWorkflowV4FreshOutputParent(root, outputDir); err != nil { + return err + } + if _, err := os.Lstat(filepath.Join(outputDir, "checkpoint.json")); errors.Is(err, os.ErrNotExist) { + command, err := workflowV4RecordCommand(snapshot, online, signer, phase+"-beacon-recorded", beaconRecord, beaconSignature, []string{rawResponse}, outputDir) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + } else if err != nil { + return err + } + return runWorkflowV4CommitCommand(online, outputDir) +} + +func fetchWorkflowV4QuicknetRound(round uint64, destination string) error { + return fetchWorkflowV4QuicknetRoundUsing(round, destination, fetchWorkflowV4QuicknetRoundFrom) +} + +func fetchWorkflowV4QuicknetRoundUsing(round uint64, destination string, fetch func(string, uint64, string) error) error { + var failures []string + for _, origin := range []string{workflowV4ProtocolLabsDrand, workflowV4CloudflareDrand} { + if err := fetch(origin, round, destination); err == nil { + return nil + } else { + failures = append(failures, err.Error()) + } + } + return fmt.Errorf("download committed drand round from every configured endpoint: %s", strings.Join(failures, "; ")) +} + +func fetchWorkflowV4QuicknetRoundFrom(origin string, round uint64, destination string) error { + if round == 0 { + return errors.New("beacon round must be positive") + } + if origin != workflowV4ProtocolLabsDrand && origin != workflowV4CloudflareDrand { + return errors.New("unsupported drand relay origin") + } + client := &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return errors.New("drand redirect refused") }} + url := fmt.Sprintf("%s/%s/public/%d", origin, workflowV4QuicknetChainHash, round) + response, err := client.Get(url) // #nosec G107 -- fixed HTTPS origin and validated integer round. + if err != nil { + return fmt.Errorf("download committed drand round: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + return fmt.Errorf("download committed drand round: HTTP %s", response.Status) + } + raw, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1)) + if err != nil { + return err + } + if len(raw) == 0 || len(raw) > 1<<20 { + return errors.New("drand response is empty or exceeds 1 MiB") + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return err + } + return setupWriteBytesNewOrExact(destination, raw, 0o600) +} + +func workflowV4BeaconCommand(online, signer guidedProfile, phase string, closure transcript.SignedArtifactRefs, response string, publishedAt time.Time) ([]string, error) { + root := filepath.Join(online.Work, "ceremony", "public") + mapWork := func(path string) (string, error) { return pathWithin(signer.Work, path, "/work") } + ceremony, err := mapWork(filepath.Join(root, "ceremony.json")) + if err != nil { + return nil, err + } + ceremonySignature, _ := mapWork(filepath.Join(root, "ceremony.sig")) + closureRecord, _ := mapWork(filepath.Join(root, filepath.FromSlash(closure.Record.Name))) + closureSignature, _ := mapWork(filepath.Join(root, filepath.FromSlash(closure.Signature.Name))) + responsePath, err := mapWork(response) + if err != nil { + return nil, err + } + transcriptRoot, _ := mapWork(root) + coordinatorKey, err := pathWithin(signer.Trust, filepath.Join(online.Trust, "setup-coordinator.hex"), "/trust") + if err != nil { + return nil, err + } + command := []string{"mpc-ceremony", phase, "beacon", "--ceremony", ceremony, "--ceremony-signature", ceremonySignature, "--coordinator-public-key-file", coordinatorKey, "--closure", closureRecord, "--closure-signature", closureSignature, "--raw-response", responsePath, "--published-at", publishedAt.UTC().Format(time.RFC3339Nano), "--coordinator-signing-key", "/keys/signing.hex", "--transcript-dir", transcriptRoot} + return command, nil +} + +func workflowV4CloseCommand(state transcript.CheckpointStateV4, online, signer guidedProfile, phase string, phaseState transcript.CheckpointPhaseState, lead uint32) ([]string, error) { + root := filepath.Join(online.Work, "ceremony", "public") + mapWork := func(path string) (string, error) { return pathWithin(signer.Work, path, "/work") } + ceremony, err := mapWork(filepath.Join(root, "ceremony.json")) + if err != nil { + return nil, err + } + ceremonySignature, _ := mapWork(filepath.Join(root, "ceremony.sig")) + transcriptRoot, _ := mapWork(root) + chain, _ := mapWork(filepath.Join(root, filepath.FromSlash(phaseState.Chain.Record.Name))) + chainSignature, _ := mapWork(filepath.Join(root, filepath.FromSlash(phaseState.Chain.Signature.Name))) + coordinatorKey, err := pathWithin(signer.Trust, filepath.Join(online.Trust, "setup-coordinator.hex"), "/trust") + if err != nil { + return nil, err + } + command := []string{"mpc-ceremony", phase, "close", "--ceremony", ceremony, "--ceremony-signature", ceremonySignature, "--coordinator-public-key-file", coordinatorKey, "--transcript-dir", transcriptRoot, "--chain", chain, "--chain-signature", chainSignature, "--coordinator-signing-key", "/keys/signing.hex", "--beacon-round-lead", strconv.FormatUint(uint64(lead), 10)} + if phase == "phase2" { + if state.Progress.Phase1Seal == nil { + return nil, errors.New("Phase 2 closure requires the authenticated Phase 1 seal") + } + seal, _ := mapWork(filepath.Join(root, filepath.FromSlash(state.Progress.Phase1Seal.Record.Name))) + sealSignature, _ := mapWork(filepath.Join(root, filepath.FromSlash(state.Progress.Phase1Seal.Signature.Name))) + command = append(command, "--phase1-seal", seal, "--phase1-seal-signature", sealSignature) + } + return command, nil +} + +// prepareWorkflowV4FreshOutputParent creates only the public parent hierarchy. +// Proof-tool remains responsible for atomically creating the fresh leaf so a +// retry cannot silently replace a signed checkpoint. +func prepareWorkflowV4FreshOutputParent(root, outputDir string) error { + if root == "" || outputDir == root { + return errors.New("lifecycle checkpoint needs a fresh child directory") + } + if _, err := pathWithin(root, outputDir, "/public"); err != nil { + return errors.New("lifecycle checkpoint output is outside the public transcript") + } + return os.MkdirAll(filepath.Dir(outputDir), 0o700) +} + +func workflowV4RecordCommand(snapshot storagefirst.SnapshotV4, online, signer guidedProfile, transition, record, signature string, evidence []string, outputDir string) ([]string, error) { + root := filepath.Join(online.Work, "ceremony", "public") + mapWork := func(path string) (string, error) { return pathWithin(signer.Work, path, "/work") } + ceremony, err := mapWork(filepath.Join(root, "ceremony.json")) + if err != nil { + return nil, err + } + ceremonySignature, _ := mapWork(filepath.Join(root, "ceremony.sig")) + artifactRoot, _ := mapWork(root) + headRecord, _ := mapWork(filepath.Join(root, filepath.FromSlash(snapshot.Head().Record.Name))) + headSignature, _ := mapWork(filepath.Join(root, filepath.FromSlash(snapshot.Head().Signature.Name))) + recordPath, err := mapWork(record) + if err != nil { + return nil, err + } + signaturePath, err := mapWork(signature) + if err != nil { + return nil, err + } + out, err := mapWork(outputDir) + if err != nil { + return nil, err + } + coordinatorKey, err := pathWithin(signer.Trust, filepath.Join(online.Trust, "setup-coordinator.hex"), "/trust") + if err != nil { + return nil, err + } + command := []string{"mpc-ceremony", "checkpoint", "record-v4", "--ceremony", ceremony, "--ceremony-signature", ceremonySignature, "--coordinator-public-key-file", coordinatorKey, "--artifact-root", artifactRoot, "--checkpoint", headRecord, "--checkpoint-signature", headSignature, "--transition", transition, "--record", recordPath, "--record-signature", signaturePath} + for _, path := range evidence { + mapped, err := mapWork(path) + if err != nil { + return nil, err + } + command = append(command, "--evidence", mapped) + } + command = append(command, "--coordinator-signing-key", "/keys/signing.hex", "--out-dir", out) + return command, nil +} diff --git a/cmd/relay/workflow_v4_phase_lifecycle_test.go b/cmd/relay/workflow_v4_phase_lifecycle_test.go new file mode 100644 index 0000000..0f974d8 --- /dev/null +++ b/cmd/relay/workflow_v4_phase_lifecycle_test.go @@ -0,0 +1,271 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/zksecurity/relay/internal/transcript" +) + +func TestPrepareWorkflowV4FreshOutputParentPreservesFreshLeaf(t *testing.T) { + root := filepath.Join(t.TempDir(), "ceremony", "public") + output := filepath.Join(root, "checkpoints", "phase1", "lifecycle", "closed-test") + if err := prepareWorkflowV4FreshOutputParent(root, output); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(filepath.Dir(output)); err != nil || !info.IsDir() { + t.Fatalf("parent was not created: %v", err) + } + if _, err := os.Lstat(output); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("fresh leaf was created or replaced: %v", err) + } + if err := prepareWorkflowV4FreshOutputParent(root, filepath.Join(root, "..", "outside")); err == nil { + t.Fatal("accepted output outside public transcript") + } +} + +func TestWorkflowV4BeaconDownloadUsesSecondEndpointOnlyAsFallback(t *testing.T) { + var tried []string + err := fetchWorkflowV4QuicknetRoundUsing(42, "/unused", func(origin string, round uint64, destination string) error { + tried = append(tried, origin) + if origin == workflowV4ProtocolLabsDrand { + return errors.New("unavailable") + } + if origin != workflowV4CloudflareDrand || round != 42 || destination != "/unused" { + t.Fatalf("unexpected fallback request: %q %d %q", origin, round, destination) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(tried) != 2 || tried[0] != workflowV4ProtocolLabsDrand || tried[1] != workflowV4CloudflareDrand { + t.Fatalf("fallback order = %#v", tried) + } + + tried = nil + if err := fetchWorkflowV4QuicknetRoundUsing(42, "/unused", func(origin string, _ uint64, _ string) error { + tried = append(tried, origin) + return nil + }); err != nil { + t.Fatal(err) + } + if len(tried) != 1 || tried[0] != workflowV4ProtocolLabsDrand { + t.Fatalf("successful primary should avoid fallback: %#v", tried) + } +} + +func TestWorkflowV4LifecycleClosesOnlyCompletedOpenPhase(t *testing.T) { + protocol := transcript.DefinitionProtocol{Definition: transcript.Definition{Phase1Participants: []string{"p1"}, Phase2Participants: []string{"p1"}}} + commitments := transcript.CheckpointCommitmentsV4{} + state := transcript.CheckpointStateV4{Progress: transcript.CheckpointProgressV4{Phase1: transcript.CheckpointPhaseState{Phase: "phase1"}}} + if action, _, err := workflowV4CoordinatorLifecycleAction(state, commitments, protocol); err != nil || action != "" { + t.Fatalf("unfinished phase action = %q, %v", action, err) + } + state.Progress.Phase1.AcceptedCount = 1 + if action, _, err := workflowV4CoordinatorLifecycleAction(state, commitments, protocol); err != nil || action != workflowV4ClosePhase1 { + t.Fatalf("completed phase1 action = %q, %v", action, err) + } + state.Progress.Phase1Closure = &transcript.SignedArtifactRefs{} + if action, _, err := workflowV4CoordinatorLifecycleAction(state, commitments, protocol); err != nil || action != workflowV4BeaconPhase1 { + t.Fatalf("closed phase1 action = %q, %v", action, err) + } + state.Progress.Phase1Beacon = &transcript.SignedArtifactRefs{} + if action, _, err := workflowV4CoordinatorLifecycleAction(state, commitments, protocol); err != nil || action != workflowV4SealPhase1 { + t.Fatalf("beacon phase1 action = %q, %v", action, err) + } + state.Progress.Phase1Seal = &transcript.SignedArtifactRefs{} + if action, _, err := workflowV4CoordinatorLifecycleAction(state, commitments, protocol); err != nil || action != workflowV4StartPhase2 { + t.Fatalf("sealed phase1 action = %q, %v", action, err) + } + state.Progress.Phase2 = &transcript.CheckpointPhaseState{Phase: "phase2", AcceptedCount: 1} + if action, _, err := workflowV4CoordinatorLifecycleAction(state, commitments, protocol); err != nil || action != workflowV4ClosePhase2 { + t.Fatalf("completed phase2 action = %q, %v", action, err) + } + state.Progress.Phase2Closure = &transcript.SignedArtifactRefs{} + if action, _, err := workflowV4CoordinatorLifecycleAction(state, commitments, protocol); err != nil || action != workflowV4BeaconPhase2 { + t.Fatalf("closed phase2 action = %q, %v", action, err) + } + state.Progress.Phase2Beacon = &transcript.SignedArtifactRefs{} + if action, _, err := workflowV4CoordinatorLifecycleAction(state, commitments, protocol); err != nil || action != workflowV4Finalize { + t.Fatalf("completed ceremony action = %q, %v", action, err) + } + state.Progress.FinalCandidate = &transcript.SignedArtifactRefs{} + if action, _, err := workflowV4CoordinatorLifecycleAction(state, commitments, protocol); err != nil || action != workflowV4Review { + t.Fatalf("final candidate review action = %q, %v", action, err) + } + state.Progress.ReleaseReview = &transcript.SignedArtifactRefs{} + if action, _, err := workflowV4CoordinatorLifecycleAction(state, commitments, protocol); err != nil || action != workflowV4Release { + t.Fatalf("signed review release action = %q, %v", action, err) + } + state.Progress.FinalRelease = &transcript.SignedArtifactRefs{} + if action, _, err := workflowV4CoordinatorLifecycleAction(state, commitments, protocol); err != nil || action != "" { + t.Fatalf("completed release action = %q, %v", action, err) + } +} + +func TestWorkflowV4BundleCommandsBindExactCheckpoint(t *testing.T) { + work, trust, keys := t.TempDir(), t.TempDir(), t.TempDir() + online := guidedProfile{Work: work, Trust: trust, Keys: keys} + signer := guidedProfile{Work: work, Trust: trust, Keys: keys} + head := pairV4Test("0012") + bundle := filepath.Join(work, "ceremony", "public", "operational", "evidence-bundle.json") + signature := filepath.Join(work, "ceremony", "public", "operational", "evidence-bundle.sig") + when := time.Date(2026, 9, 16, 1, 2, 3, 4, time.UTC) + prepare, err := workflowV4BundleCommand(head, online, signer, "prepare", bundle, signature, "", when) + if err != nil { + t.Fatal(err) + } + sign, err := workflowV4BundleCommand(head, online, signer, "sign", bundle, signature, strings.Repeat("a", 64), time.Time{}) + if err != nil { + t.Fatal(err) + } + for command, wants := range map[string][]string{ + strings.Join(prepare, " "): {"ops prepare-bundle-v4", "--checkpoint /work/ceremony/public/checkpoints/0012/checkpoint.json", "--assembled-at " + when.Format(time.RFC3339Nano), "--out /work/ceremony/public/operational/evidence-bundle.json"}, + strings.Join(sign, " "): {"ops sign-bundle-v4", "--checkpoint-signature /work/ceremony/public/checkpoints/0012/checkpoint.sig", "--reviewed-sha256 " + strings.Repeat("a", 64), "--out /work/ceremony/public/operational/evidence-bundle.sig"}, + } { + for _, want := range wants { + if !strings.Contains(command, want) { + t.Fatalf("command %q lacks %q", command, want) + } + } + } +} + +func TestWorkflowV4SealAndPhase2CommandsUseAuthenticatedPairs(t *testing.T) { + work, trust, keys := t.TempDir(), t.TempDir(), t.TempDir() + online := guidedProfile{Work: work, Trust: trust, Keys: keys} + signer := guidedProfile{Work: work, Trust: trust, Keys: keys} + closure, beacon, seal := pairV4Test("closure"), pairV4Test("beacon"), pairV4Test("seal") + sealCommand, err := workflowV4SealCommand(online, signer, closure, beacon, filepath.Join(work, "ceremony", "public", "phase1", "sealed")) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(sealCommand, " ") + for _, want := range []string{"phase1 seal", "--closure /work/ceremony/public/" + closure.Record.Name, "--beacon /work/ceremony/public/" + beacon.Record.Name, "--out-dir /work/ceremony/public/phase1/sealed"} { + if !strings.Contains(joined, want) { + t.Fatalf("seal command %q lacks %q", joined, want) + } + } + phase2Command, err := workflowV4Phase2InitCommand(online, signer, seal, filepath.Join(work, "ceremony", "public", "phase2")) + if err != nil { + t.Fatal(err) + } + joined = strings.Join(phase2Command, " ") + for _, want := range []string{"phase2 init", "--phase1-seal /work/ceremony/public/" + seal.Record.Name, "--out-dir /work/ceremony/public/phase2"} { + if !strings.Contains(joined, want) { + t.Fatalf("phase2 command %q lacks %q", joined, want) + } + } +} + +func TestWorkflowV4BeaconCommandUsesCommittedClosure(t *testing.T) { + work, trust, keys := t.TempDir(), t.TempDir(), t.TempDir() + online := guidedProfile{Work: work, Trust: trust, Keys: keys} + signer := guidedProfile{Work: work, Trust: trust, Keys: keys} + closure := pairV4Test("phase1-closure") + response := filepath.Join(work, "workflow-v4", "beacons", "phase1-round-10.json") + when := time.Date(2026, 9, 16, 1, 2, 3, 4, time.UTC) + command, err := workflowV4BeaconCommand(online, signer, "phase1", closure, response, when) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(command, " ") + for _, want := range []string{ + "mpc-ceremony phase1 beacon", + "--closure /work/ceremony/public/" + closure.Record.Name, + "--closure-signature /work/ceremony/public/" + closure.Signature.Name, + "--raw-response /work/workflow-v4/beacons/phase1-round-10.json", + "--published-at " + when.Format(time.RFC3339Nano), + } { + if !strings.Contains(joined, want) { + t.Fatalf("command %q lacks %q", joined, want) + } + } +} + +func TestWorkflowV4CloseCommandUsesAuthenticatedPhaseFiles(t *testing.T) { + work, trust, keys := t.TempDir(), t.TempDir(), t.TempDir() + online := guidedProfile{Work: work, Trust: trust, Keys: keys} + signer := guidedProfile{Work: work, Trust: trust, Keys: keys} + phase := transcript.CheckpointPhaseState{Phase: "phase1", Chain: pairV4Test("phase1-chain")} + command, err := workflowV4CloseCommand(transcript.CheckpointStateV4{}, online, signer, "phase1", phase, 37) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(command, " ") + for _, want := range []string{ + "mpc-ceremony phase1 close", + "--transcript-dir /work/ceremony/public", + "--chain " + filepath.ToSlash(filepath.Join("/work", "ceremony", "public", phase.Chain.Record.Name)), + "--chain-signature " + filepath.ToSlash(filepath.Join("/work", "ceremony", "public", phase.Chain.Signature.Name)), + "--beacon-round-lead 37", + } { + if !strings.Contains(joined, want) { + t.Fatalf("command %q lacks %q", joined, want) + } + } +} + +func TestWorkflowV4FinalizeCommandsUseCompleteAuthenticatedReplay(t *testing.T) { + work, trust, keys := t.TempDir(), t.TempDir(), t.TempDir() + online := guidedProfile{Work: work, Trust: trust, Keys: keys} + signer := guidedProfile{Work: work, Trust: trust, Keys: keys} + state := transcript.CheckpointStateV4{CeremonyID: "sha256:" + strings.Repeat("a", 64)} + state.Progress.Phase1 = transcript.CheckpointPhaseState{Phase: "phase1", Chain: pairV4Test("phase1/chain")} + state.Progress.Phase1Closure = pointerPairV4Test("phase1/closure") + state.Progress.Phase1Beacon = pointerPairV4Test("phase1/beacon") + state.Progress.Phase1Seal = pointerPairV4Test("phase1/seal") + state.Progress.Phase2 = &transcript.CheckpointPhaseState{Phase: "phase2", Chain: pairV4Test("phase2/chain")} + state.Progress.Phase2Closure = pointerPairV4Test("phase2/closure") + state.Progress.Phase2Beacon = pointerPairV4Test("phase2/beacon") + when := time.Date(2026, 9, 16, 1, 2, 3, 4, time.UTC) + output := filepath.Join(work, "ceremony", "public", "final", "candidate") + evidence := filepath.Join(work, "ceremony", "public", "final", "public-finalization-evidence.json") + command, err := workflowV4FinalizeCommand(state, online, signer, "complete", output, evidence, when) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(command, " ") + for _, want := range []string{ + "mpc-ceremony finalize complete", + "--phase1-chain /work/ceremony/public/" + state.Progress.Phase1.Chain.Record.Name, + "--phase1-seal /work/ceremony/public/" + state.Progress.Phase1Seal.Record.Name, + "--phase2-chain /work/ceremony/public/" + state.Progress.Phase2.Chain.Record.Name, + "--phase2-beacon /work/ceremony/public/" + state.Progress.Phase2Beacon.Record.Name, + "--public-evidence /work/ceremony/public/final/public-finalization-evidence.json", + "--out-dir /work/ceremony/public/final/candidate", + } { + if !strings.Contains(joined, want) { + t.Fatalf("finalize command %q lacks %q", joined, want) + } + } +} + +func TestWorkflowV4FinalCandidateEvidenceMatchesClosedTree(t *testing.T) { + candidate := "/work/ceremony/public/final/candidate" + want := []string{ + "ownership-destination.ccs", "ownership.pk", "ownership.vk", + "cardano-vk.bin", "cardano-vk.hex", "cardano-vk-format.txt", + "verification-report.json", "candidate-checksums.sha256", + "public-finalization-evidence.json", "phase2-seal.json", "phase2-seal.sig.json", + } + evidence := workflowV4FinalCandidateEvidence(candidate) + if len(evidence) != len(want) { + t.Fatalf("evidence count = %d, want %d: %#v", len(evidence), len(want), evidence) + } + for index, name := range want { + if evidence[index] != filepath.Join(candidate, name) { + t.Fatalf("evidence[%d] = %q, want %q", index, evidence[index], filepath.Join(candidate, name)) + } + } +} + +func pointerPairV4Test(name string) *transcript.SignedArtifactRefs { + pair := pairV4Test(name) + return &pair +} diff --git a/cmd/relay/workflow_v4_plan.go b/cmd/relay/workflow_v4_plan.go new file mode 100644 index 0000000..07ad88d --- /dev/null +++ b/cmd/relay/workflow_v4_plan.go @@ -0,0 +1,330 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +// prepareWorkflowV4Contribution freezes the completely verified public +// snapshot that proof-tool will authenticate again in the contributor process. +// It prepares no journal entry and starts no container. +func prepareWorkflowV4Contribution(snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, binding workflowV4Binding, participant access.RoleConfig, now time.Time) (workflowV4OperationPlan, string, error) { + var zero workflowV4OperationPlan + if binding.Role != "participant" || participant.IdentityID != binding.IdentityID || now.IsZero() { + return zero, "", errors.New("participant profile, identity and contribution time are required") + } + phase := participant.Phase + view, err := snapshot.TurnV4(protocol, phase, binding.IdentityID) + if err != nil { + return zero, "", err + } + if view.Stage != storagefirst.TurnCandidateV4 || view.CandidateAttempt == nil { + return zero, "", errors.New("authenticated ceremony state has no active candidate allocation for this participant") + } + id, err := randomID() + if err != nil { + return zero, "", err + } + inputRoot := filepath.Join(binding.Work, "workflow-v4", "inputs", id) + if err := stageWorkflowV4Snapshot(binding.Work, participant.Root, inputRoot, snapshot.Files()); err != nil { + return zero, "", err + } + stateView, err := snapshot.State() + if err != nil { + return zero, "", err + } + phaseState := stateView.Progress.Phase1 + if phase == "phase2" { + if stateView.Progress.Phase2 == nil { + return zero, "", errors.New("authenticated phase2 state is missing") + } + phaseState = *stateView.Progress.Phase2 + } + allocation := snapshot.Head() + if allocation.Record.Name == "" || allocation.Signature.Name == "" { + return zero, "", errors.New("authenticated allocation checkpoint references are missing") + } + fullRefs := map[string]transcript.ArtifactRef{} + add := func(ref transcript.ArtifactRef) { fullRefs[ref.Name] = ref } + for _, ref := range []transcript.ArtifactRef{binding.Definition.Record, binding.Definition.Signature, phaseState.Chain.Record, phaseState.Chain.Signature, phaseState.HeadPayload, allocation.Record, allocation.Signature} { + add(ref) + } + if phase == "phase2" { + if stateView.Progress.Phase1Seal == nil { + return zero, "", errors.New("phase2 contribution requires the authenticated phase1 seal") + } + add(stateView.Progress.Phase1Seal.Record) + add(stateView.Progress.Phase1Seal.Signature) + } + // The signed allocation and its ancestry bind the complete frozen snapshot. + // Retain only direct command/boundary references in the local journal; the + // approved proof-tool walks and verifies every referenced artifact from + // inputRoot in the same process immediately before it samples randomness. + // Recording the whole growing history in every operation would make the + // journal quadratic in the number of turns. + snapshotRefs := make(map[string]state.ContentRef, len(snapshot.Files())) + for _, ref := range snapshot.Files() { + snapshotRefs[ref.Name] = ref + } + names := make([]string, 0, len(fullRefs)) + for name := range fullRefs { + names = append(names, name) + } + slices.Sort(names) + inputs := make([]workflowV4Input, 0, len(names)+2) + for _, name := range names { + artifact := fullRefs[name] + ref, ok := snapshotRefs[name] + if !ok || artifact.Digest.SHA256 != ref.SHA256 || artifact.Digest.Size != ref.Size { + return zero, "", errors.New("verified snapshot reference differs from authenticated checkpoint state") + } + inputs = append(inputs, workflowV4Input{Path: filepath.Join(inputRoot, filepath.FromSlash(name)), Ref: artifact}) + } + for _, local := range []struct { + name string + path string + }{{"coordinator-public-key.hex", participant.CoordinatorKey}, {"environment.json", participant.Environment}} { + ref, err := workflowV4LocalRef(local.name, local.path) + if err != nil { + return zero, "", err + } + inputs = append(inputs, workflowV4Input{Path: local.path, Ref: ref}) + } + output := filepath.Join(participant.RunRoot, "candidates", fmt.Sprintf("%s-%02d-%s", phase, view.Scope.Index, view.CandidateAttempt.AttemptID)) + o := roleOpts{root: inputRoot, definition: filepath.Join(inputRoot, filepath.FromSlash(binding.Definition.Record.Name)), definitionSig: filepath.Join(inputRoot, filepath.FromSlash(binding.Definition.Signature.Name)), coordinatorKey: participant.CoordinatorKey, phase: phase, role: binding.IdentityID, signingKey: participant.SigningKey, envPath: participant.Environment, outDir: output, operationID: id, artifactRoot: inputRoot, checkpoint: filepath.Join(inputRoot, filepath.FromSlash(allocation.Record.Name)), checkpointSig: filepath.Join(inputRoot, filepath.FromSlash(allocation.Signature.Name)), attemptID: view.CandidateAttempt.AttemptID} + if phase == "phase2" { + o.phase1Seal = filepath.Join(inputRoot, filepath.FromSlash(stateView.Progress.Phase1Seal.Record.Name)) + o.phase1SealSig = filepath.Join(inputRoot, filepath.FromSlash(stateView.Progress.Phase1Seal.Signature.Name)) + } + position := position{nextID: binding.IdentityID, nextIndex: int(view.Scope.Index), chainPath: filepath.Join(inputRoot, filepath.FromSlash(phaseState.Chain.Record.Name))} + position.chain.ChainSignaturePath = filepath.Join(inputRoot, filepath.FromSlash(phaseState.Chain.Signature.Name)) + command := append([]string{"mpc-ceremony"}, contributionCommandArgs(o, position, now.UTC().Truncate(time.Second))...) + for n, value := range command { + if !filepath.IsAbs(value) { + continue + } + command[n], err = workflowV4ContainerPath(binding.Runtimes["contributor"], value) + if err != nil { + return zero, "", err + } + } + plan := workflowV4OperationPlan{ID: id, Kind: "contribute", Scope: view.Scope, Predecessor: phaseState.Chain, Allocation: allocation, AttemptID: view.CandidateAttempt.AttemptID, Runtime: binding.Runtimes["contributor"], Command: command, Inputs: inputs, Outputs: []string{output}} + if err := validateWorkflowV4Plan(plan, binding); err != nil { + return zero, "", err + } + scopePath := filepath.Join(binding.Work, "workflow-v4", "scopes", view.CandidateAttempt.AttemptID+".json") + if err := writeWorkflowV4Scope(scopePath, view.Scope); err != nil { + return zero, "", err + } + return plan, scopePath, nil +} + +// prepareWorkflowV4Erasure binds cleanup signing to one reconciled +// contribution. It never asks proof-tool to generate contribution randomness. +func (j *workflowV4Journal) prepareWorkflowV4Erasure(contributionID, scopePath string, generated transcript.ComputationOutputFactsV4, destroyedAt time.Time) (workflowV4OperationPlan, error) { + var zero workflowV4OperationPlan + if j.lock == nil || j.writeErr != nil || destroyedAt.IsZero() { + return zero, errors.New("active V4 workspace and cleanup confirmation time required") + } + var contribution *workflowV4Operation + for n := range j.state.Operations { + candidate := &j.state.Operations[n] + if candidate.Plan.ID == contributionID { + contribution = candidate + break + } + } + if contribution == nil || contribution.Status != "reconciled" || contribution.Plan.Kind != "contribute" { + return zero, errors.New("reconciled contribution required before cleanup signing") + } + prior := contribution.Plan + if generated.Scope != prior.Scope || generated.Predecessor != prior.Predecessor || len(generated.Files) != 3 { + return zero, errors.New("verified computation output differs from the retained contribution") + } + for n, name := range []string{"attestation.json", "attestation.sig", "contribution.bin"} { + if generated.Files[n].Name != name { + return zero, errors.New("verified computation output has an unexpected inventory") + } + } + var retainedScope transcript.ContributionScopeV4 + if err := readWorkflowV4JSON(scopePath, &retainedScope); err != nil || retainedScope != prior.Scope { + return zero, errors.New("retained contribution scope differs from cleanup operation") + } + candidateDir := prior.Outputs[0] + inputs := append([]workflowV4Input(nil), prior.Inputs...) + for _, ref := range generated.Files { + inputs = append(inputs, workflowV4Input{Path: filepath.Join(candidateDir, ref.Name), Ref: ref}) + } + lifecycle, err := workflowV4LocalRef(dockerLifecycleLogName, filepath.Join(candidateDir, dockerLifecycleLogName)) + if err != nil { + return zero, err + } + inputs = append(inputs, workflowV4Input{Path: filepath.Join(candidateDir, dockerLifecycleLogName), Ref: lifecycle}) + o, _, _, err := workflowV4ContributionOptions(prior) + if err != nil { + return zero, err + } + id, err := randomID() + if err != nil { + return zero, err + } + runtime := j.state.Marker.Binding.Runtimes["signer"] + container := func(path string) (string, error) { return workflowV4ContainerPath(runtime, path) } + definition, err := container(o.definition) + if err != nil { + return zero, err + } + definitionSig, err := container(o.definitionSig) + if err != nil { + return zero, err + } + coordinatorKey, err := container(o.coordinatorKey) + if err != nil { + return zero, err + } + candidateContainer, err := container(candidateDir) + if err != nil { + return zero, err + } + stamp := destroyedAt.UTC().Truncate(time.Second).Format(time.RFC3339) + command := []string{"mpc-ceremony", prior.Scope.Phase, "attest-erasure", "--ceremony", definition, "--ceremony-signature", definitionSig, "--coordinator-public-key-file", coordinatorKey, "--participant-id", prior.Scope.ParticipantID, "--participant-signing-key", "/keys/signing.hex", "--candidate-dir", candidateContainer, "--destroyed-at", stamp} + plan := workflowV4OperationPlan{ID: id, Kind: "attest-erasure", Scope: prior.Scope, Predecessor: prior.Predecessor, AttemptID: prior.AttemptID, Runtime: runtime, Command: command, Inputs: inputs, Outputs: []string{filepath.Join(candidateDir, "erasure.json"), filepath.Join(candidateDir, "erasure.sig")}} + if err := validateWorkflowV4Plan(plan, j.state.Marker.Binding); err != nil { + return zero, err + } + return plan, nil +} + +func workflowV4LocalRef(name, path string) (transcript.ArtifactRef, error) { + sha, blake, size, err := transcript.DigestFileBoth(path) + if err != nil { + return transcript.ArtifactRef{}, err + } + return transcript.ArtifactRef{Name: name, Digest: transcript.Digest{SHA256: sha, Blake2b256: blake, Size: size}}, nil +} + +func writeWorkflowV4Scope(path string, scope transcript.ContributionScopeV4) error { + raw, err := json.Marshal(scope) + if err != nil { + return err + } + if err := ensureWorkflowV4Directory(filepath.Dir(filepath.Dir(filepath.Dir(path))), filepath.Dir(path)); err != nil { + return err + } + if existing, err := os.ReadFile(path); err == nil { + if string(existing) != string(raw) { + return errors.New("existing V4 scope file belongs to another turn") + } + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + if _, err := file.Write(raw); err != nil { + _ = file.Close() + return err + } + if err := file.Sync(); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return syncDirectory(filepath.Dir(path)) +} + +func stageWorkflowV4Snapshot(workRoot, sourceRoot, destinationRoot string, refs []state.ContentRef) error { + if len(refs) == 0 { + return errors.New("authenticated V4 snapshot has no retained files") + } + if _, err := os.Lstat(destinationRoot); err == nil || !errors.Is(err, os.ErrNotExist) { + return errors.New("fresh V4 contribution snapshot directory required") + } + parent := filepath.Dir(destinationRoot) + if err := ensureWorkflowV4Directory(workRoot, parent); err != nil { + return err + } + temporary, err := os.MkdirTemp(parent, ".relay-v4-inputs-") + if err != nil { + return err + } + complete := false + defer func() { + if !complete { + _ = os.RemoveAll(temporary) + } + }() + for _, ref := range refs { + source := filepath.Join(sourceRoot, filepath.FromSlash(ref.Name)) + destination := filepath.Join(temporary, filepath.FromSlash(ref.Name)) + if err := os.MkdirAll(filepath.Dir(destination), 0700); err != nil { + return err + } + input, err := os.Open(source) + if err != nil { + return err + } + output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + _ = input.Close() + return err + } + _, copyErr := io.Copy(output, io.LimitReader(input, ref.Size+1)) + inputErr := input.Close() + syncErr := output.Sync() + outputErr := output.Close() + if copyErr != nil || inputErr != nil || syncErr != nil || outputErr != nil { + return errors.Join(copyErr, inputErr, syncErr, outputErr) + } + sha, size, err := transcript.DigestFile(destination) + if err != nil || sha != ref.SHA256 || size != ref.Size { + return errors.New("retained V4 snapshot file changed while freezing inputs") + } + } + if err := os.Rename(temporary, destinationRoot); err != nil { + return err + } + complete = true + return syncDirectory(parent) +} + +func ensureWorkflowV4Directory(root, target string) error { + if !filepath.IsAbs(root) || filepath.Clean(root) != root || !filepath.IsAbs(target) || filepath.Clean(target) != target { + return errors.New("V4 workspace directories must use absolute clean paths") + } + relative, err := filepath.Rel(root, target) + if err != nil || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errors.New("V4 workspace directory escapes its root") + } + current := root + for _, component := range append([]string{"."}, strings.Split(relative, string(filepath.Separator))...) { + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + if err := os.Mkdir(current, 0700); err != nil { + return err + } + continue + } + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0077 != 0 { + return errors.New("V4 workspace path must contain only private real directories") + } + } + return nil +} diff --git a/cmd/relay/workflow_v4_profile.go b/cmd/relay/workflow_v4_profile.go new file mode 100644 index 0000000..86054e2 --- /dev/null +++ b/cmd/relay/workflow_v4_profile.go @@ -0,0 +1,69 @@ +package main + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/transcript" +) + +// Bind the new workflow to existing onboarding profiles. This does not migrate +// legacy completion records or select a replacement runtime for interrupted work. +func workflowV4ProfileBinding(p, signer guidedProfile, protocol transcript.DefinitionProtocol, identity setupIdentity, participant *access.RoleConfig) (workflowV4Binding, error) { + var zero workflowV4Binding + if !protocol.UsesV4() || (p.Role != "coordinator" && p.Role != "participant" && p.Role != "release-signer" && p.Role != "auditor") || len(p.Command) != 0 { + return zero, errors.New("storage-first guide requires an authenticated V4 coordinator, participant, auditor or release-signer profile") + } + if err := identity.check(); err != nil { + return zero, err + } + journey, err := protocol.Definition.RequireJourney() + if err != nil { + return zero, err + } + assigned := false + for _, e := range journey.RequiredEnrollments { + if e.Role == p.Role && e.Identity.ID == identity.ID && e.Identity.KeyID == identity.KeyID && e.Identity.Ed25519PublicKeyHex == identity.PublicKey && e.Identity.PublicKeyFingerprint == identity.Fingerprint { + assigned = true + } + } + if !assigned { + return zero, errors.New("your saved identity does not match this role in the authenticated ceremony") + } + if signer.Role != "decision-signer" || signer.Name != offlineRoleAlias(p.Name, p.Role) || signer.Work != p.Work || signer.Trust != p.Trust || signer.Keys != p.Keys || signer.ReleaseCommit != p.ReleaseCommit || signer.Platform != p.Platform || len(signer.Command) != 0 || signer.Credentials != "" || signer.R2Parent != "" || signer.R2Control != "" { + return zero, errors.New("network-disabled signing profile must match this role's folders, platform and release") + } + runtime := func(image, platform string) workflowV4Runtime { + return workflowV4Runtime{Image: image, Platform: platform, Mounts: map[string]string{"/work": p.Work, "/trust": p.Trust, "/keys": p.Keys}} + } + b := workflowV4Binding{CeremonyID: protocol.Definition.CeremonyID, Definition: protocol.DefinitionRefs, Name: p.Name, Role: p.Role, IdentityID: identity.ID, Work: p.Work, Runtimes: map[string]workflowV4Runtime{"online": runtime(p.Image, p.Platform), "signer": runtime(signer.Image, signer.Platform)}} + if p.Role == "participant" { + if participant == nil { + return zero, errors.New("prepare your participant phase profile first") + } + if err := participant.Validate(); err != nil { + return zero, err + } + if participant.CeremonyBinary != "/usr/local/bin/mpc-ceremony" || participant.CeremonyHome != filepath.Join(p.Work, "ceremony") { + return zero, errors.New("participant profile must use the saved ceremony folder and the image's fixed proof-tool executable") + } + if participant.Role != "participant" || participant.IdentityID != identity.ID || participant.CeremonyID != b.CeremonyID || participant.ExecutionMode != "docker" || participant.DockerImage != p.Image || participant.DockerPlatform != p.Platform || participant.Root != filepath.Join(p.Work, "ceremony", "public") || participant.Ceremony != filepath.Join(participant.Root, "ceremony.json") || participant.CeremonySignature != filepath.Join(participant.Root, "ceremony.sig") || participant.SigningKey != filepath.Join(p.Keys, "signing.hex") { + return zero, errors.New("participant phase profile does not match the saved ceremony, identity, folders and runtime") + } + if _, err := pathWithin(p.Trust, participant.CoordinatorKey, "/trust"); err != nil { + return zero, fmt.Errorf("participant trust anchor: %w", err) + } + for _, path := range []string{participant.Environment, participant.RunRoot, participant.StorageConfig} { + if _, err := pathWithin(p.Work, path, "/work"); err != nil { + return zero, fmt.Errorf("participant workspace input: %w", err) + } + } + b.Runtimes["contributor"] = runtime(participant.DockerImage, participant.DockerPlatform) + } + if err := validateWorkflowV4Binding(b); err != nil { + return zero, err + } + return b, nil +} diff --git a/cmd/relay/workflow_v4_profile_test.go b/cmd/relay/workflow_v4_profile_test.go new file mode 100644 index 0000000..647d09e --- /dev/null +++ b/cmd/relay/workflow_v4_profile_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/transcript" +) + +func TestWorkflowV4ProfileBinding(t *testing.T) { + protocol, existing := workflowV4TestBinding(t) + r := existing.Runtimes["online"] + p := guidedProfile{Name: "coordinator-test", Role: "coordinator", Work: existing.Work, Trust: r.Mounts["/trust"], Keys: r.Mounts["/keys"], Image: r.Image, Platform: r.Platform, ReleaseCommit: strings.Repeat("a", 40)} + signer := p + signer.Role, signer.Name = "decision-signer", offlineRoleAlias(p.Name, p.Role) + identity := setupIdentity{ID: "coordinator-test", DisplayName: "Coordinator", KeyID: "coordinator-key", PublicKey: strings.Repeat("00", 32), Fingerprint: fmt.Sprintf("sha256:%x", sha256.Sum256(make([]byte, 32)))} + for n := range protocol.Definition.Journey.RequiredEnrollments { + e := &protocol.Definition.Journey.RequiredEnrollments[n] + if e.Role == "coordinator" { + e.Identity.KeyID, e.Identity.Ed25519PublicKeyHex, e.Identity.PublicKeyFingerprint = identity.KeyID, identity.PublicKey, identity.Fingerprint + } + } + b, err := workflowV4ProfileBinding(p, signer, protocol, identity, nil) + if err != nil { + t.Fatal(err) + } + if b.Definition != protocol.DefinitionRefs || b.IdentityID != identity.ID || len(b.Runtimes) != 2 || b.Runtimes["signer"].Mounts["/keys"] != p.Keys { + t.Fatal("incorrect saved profile binding") + } + for _, change := range []func(*guidedProfile){ + func(s *guidedProfile) { s.Name += "-other" }, + func(s *guidedProfile) { s.Work = t.TempDir() }, + func(s *guidedProfile) { s.Keys = t.TempDir() }, + func(s *guidedProfile) { s.ReleaseCommit = strings.Repeat("b", 40) }, + func(s *guidedProfile) { s.Platform = "linux/amd64" }, + func(s *guidedProfile) { s.Image = "mutable:latest" }, + func(s *guidedProfile) { s.Command = []string{"mpc-ceremony", "ops", "sign"} }, + func(s *guidedProfile) { s.Credentials = "/private/credentials" }, + func(s *guidedProfile) { s.R2Parent = "/private/parent" }, + func(s *guidedProfile) { s.R2Control = "/private/control" }, + } { + bad := signer + change(&bad) + if _, err := workflowV4ProfileBinding(p, bad, protocol, identity, nil); err == nil { + t.Fatal("mismatched signer accepted") + } + } + identity.KeyID = "other-key" + if _, err := workflowV4ProfileBinding(p, signer, protocol, identity, nil); err == nil { + t.Fatal("wrong identity accepted") + } +} + +func TestWorkflowV4ParticipantProfileBinding(t *testing.T) { + protocol, b := workflowV4TestBinding(t) + r := b.Runtimes["online"] + p := guidedProfile{Name: b.Name, Role: b.Role, Work: b.Work, Trust: r.Mounts["/trust"], Keys: r.Mounts["/keys"], Image: r.Image, Platform: r.Platform} + signer := p + signer.Role, signer.Name = "decision-signer", offlineRoleAlias(p.Name, p.Role) + id := setupIdentity{ID: b.IdentityID, DisplayName: "Participant", KeyID: "participant-key", PublicKey: strings.Repeat("00", 32), Fingerprint: fmt.Sprintf("sha256:%x", sha256.Sum256(make([]byte, 32)))} + for n := range protocol.Definition.Journey.RequiredEnrollments { + e := &protocol.Definition.Journey.RequiredEnrollments[n] + if e.Role == "participant" { + e.Identity.KeyID, e.Identity.Ed25519PublicKeyHex, e.Identity.PublicKeyFingerprint = id.KeyID, id.PublicKey, id.Fingerprint + } + } + root := filepath.Join(p.Work, "ceremony", "public") + c := access.RoleConfig{Schema: access.RoleConfigSchema, Role: "participant", IdentityID: id.ID, Phase: "phase1", CeremonyID: b.CeremonyID, CeremonyHome: filepath.Dir(root), Root: root, Ceremony: filepath.Join(root, "ceremony.json"), CeremonySignature: filepath.Join(root, "ceremony.sig"), CoordinatorKey: filepath.Join(p.Trust, "coordinator-public-key.hex"), CeremonyBinary: "/usr/local/bin/mpc-ceremony", SigningKey: filepath.Join(p.Keys, "signing.hex"), Environment: filepath.Join(p.Work, "environment.json"), RunRoot: filepath.Join(p.Work, "runs"), StorageConfig: filepath.Join(p.Work, "storage.json"), PublishedBaseURL: "https://public.example.test", PublishedBucket: "published", ExecutionMode: "docker", DockerImage: p.Image, DockerPlatform: p.Platform} + c.DockerCLI = "docker" + if _, err := workflowV4ProfileBinding(p, signer, protocol, id, &c); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*access.RoleConfig){ + func(c *access.RoleConfig) { c.IdentityID = "other" }, + func(c *access.RoleConfig) { c.Root = t.TempDir() }, + func(c *access.RoleConfig) { c.SigningKey = filepath.Join(t.TempDir(), "signing.hex") }, + func(c *access.RoleConfig) { c.DockerImage = "example.test/role@sha256:" + strings.Repeat("e", 64) }, + func(c *access.RoleConfig) { c.CoordinatorKey = filepath.Join(t.TempDir(), "key.hex") }, + } { + bad := c + mutate(&bad) + if _, err := workflowV4ProfileBinding(p, signer, protocol, id, &bad); err == nil { + t.Fatal("mismatched participant profile accepted") + } + } +} + +func TestWorkflowV4ReleaseSignerProfileBinding(t *testing.T) { + protocol, existing := workflowV4TestBinding(t) + r := existing.Runtimes["online"] + p := guidedProfile{Name: "release-test", Role: "release-signer", Work: existing.Work, Trust: r.Mounts["/trust"], Keys: r.Mounts["/keys"], Image: r.Image, Platform: r.Platform, ReleaseCommit: strings.Repeat("a", 40)} + signer := p + signer.Role, signer.Name = "decision-signer", offlineRoleAlias(p.Name, p.Role) + id := setupIdentity{ID: "release-signer-test", DisplayName: "Release signer", KeyID: "release-key", PublicKey: strings.Repeat("01", 32), Fingerprint: fmt.Sprintf("sha256:%x", sha256.Sum256(bytes.Repeat([]byte{1}, 32)))} + found := false + for n := range protocol.Definition.Journey.RequiredEnrollments { + e := &protocol.Definition.Journey.RequiredEnrollments[n] + if e.Role == "release-signer" { + e.Identity = transcript.PublicIdentity{ID: id.ID, DisplayName: id.DisplayName, KeyID: id.KeyID, Ed25519PublicKeyHex: id.PublicKey, PublicKeyFingerprint: id.Fingerprint} + found = true + } + } + if !found { + t.Fatal("fixture lacks release signer") + } + binding, err := workflowV4ProfileBinding(p, signer, protocol, id, nil) + if err != nil { + t.Fatal(err) + } + if binding.Role != "release-signer" || binding.IdentityID != id.ID || len(binding.Runtimes) != 2 { + t.Fatalf("release signer binding = %#v", binding) + } +} + +func TestWorkflowV4AuditorProfileBinding(t *testing.T) { + protocol, existing := workflowV4TestBinding(t) + r := existing.Runtimes["online"] + p := guidedProfile{Name: "auditor-test", Role: "auditor", Work: existing.Work, Trust: r.Mounts["/trust"], Keys: r.Mounts["/keys"], Image: r.Image, Platform: r.Platform} + signer := p + signer.Role, signer.Name = "decision-signer", offlineRoleAlias(p.Name, p.Role) + id := setupIdentity{ID: "auditor-test", DisplayName: "Auditor", KeyID: "auditor-key", PublicKey: strings.Repeat("02", 32), Fingerprint: fmt.Sprintf("sha256:%x", sha256.Sum256(bytes.Repeat([]byte{2}, 32)))} + protocol.Definition.Journey.MinimumPassingCeremonyAudits = 1 + protocol.Definition.Journey.RequiredEnrollments = append(protocol.Definition.Journey.RequiredEnrollments, transcript.ExpectedEnrollment{Role: "auditor", RoleIndex: 1, Identity: transcript.PublicIdentity{ID: id.ID, DisplayName: id.DisplayName, KeyID: id.KeyID, Ed25519PublicKeyHex: id.PublicKey, PublicKeyFingerprint: id.Fingerprint}}) + binding, err := workflowV4ProfileBinding(p, signer, protocol, id, nil) + if err != nil { + t.Fatal(err) + } + if binding.Role != "auditor" || binding.IdentityID != id.ID || len(binding.Runtimes) != 2 { + t.Fatalf("auditor binding = %#v", binding) + } +} diff --git a/cmd/relay/workflow_v4_reconcile.go b/cmd/relay/workflow_v4_reconcile.go new file mode 100644 index 0000000..6538411 --- /dev/null +++ b/cmd/relay/workflow_v4_reconcile.go @@ -0,0 +1,164 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "runtime" + "strings" + + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +// reconcileCandidateOperation verifies outputs without rerunning either child. +// Returned facts are for this exact scope and must be refreshed for later work. +func (j *workflowV4Journal) reconcileCandidateOperation(id, scopeFile, dockerCLI string) (storagefirst.LocalTurnV4, error) { + var facts storagefirst.LocalTurnV4 + if j.lock == nil || j.writeErr != nil { + return facts, errors.New("active healthy V4 workspace required") + } + var op *workflowV4Operation + for n := range j.state.Operations { + if j.state.Operations[n].Plan.ID == id { + op = &j.state.Operations[n] + break + } + } + if op == nil || op.Plan.ID != id || (op.Plan.Kind != "contribute" && op.Plan.Kind != "attest-erasure") { + return facts, errors.New("retained contribution or cleanup operation required") + } + if op.Status != "running" && op.Status != "returned-needs-verification" && op.Status != "reconciled" { + return facts, errors.New("operation has not produced inspectable output") + } + if !filepath.IsAbs(dockerCLI) || filepath.Clean(dockerCLI) != dockerCLI { + return facts, errors.New("exact local Docker CLI path required") + } + inspect := func(p workflowV4OperationPlan) error { + if err := workflowV4InputsMatch(p); err != nil { + return err + } + candidate := p.Outputs[0] + if p.Kind == "attest-erasure" { + candidate = filepath.Dir(candidate) + } + var receipt dockerLifecycleReceipt + if err := readWorkflowV4JSON(filepath.Join(candidate, dockerLifecycleLogName), &receipt); err != nil { + return err + } + if p.Kind == "contribute" { + if err := validateWorkflowV4OrderedTimes(receipt.ExecutionMode, []string{receipt.CreatedAt, receipt.StartedAt, receipt.ExitedAt, receipt.RemovedAt}); err != nil { + return err + } + if err := j.validateContributionLifecycle(p, receipt, candidate); err != nil { + return err + } + } else { + if err := j.validateCleanupContribution(p, receipt, candidate); err != nil { + return err + } + if err := validateWorkflowV4LifecycleTimes(receipt); err != nil { + return err + } + if receipt.ParticipantConfirmation != "CLEANUP PRECAUTIONS CONFIRMED" { + return errors.New("missing recorded cleanup confirmation") + } + if err := workflowV4InputsMatch(p); err != nil { + return err + } + } + if receipt.Schema != dockerLifecycleSchema || receipt.ExecutionMode != dockerExecutionMode || !receipt.RemovalVerified || receipt.ExitCode != 0 || !validContainerID(receipt.ContainerID) || !verifiedDaemonFacts(receipt.Daemon) || !verifiedLifecycleFacts(receipt.Security) || !verifiedHostSwapStatus(runtime.GOOS, receipt.HostSwapStatus) { + return errors.New("retained contributor lifecycle is incomplete") + } + if err := validateLocalDockerEndpoint(receipt.Daemon.Endpoint); err != nil { + return err + } + client := osDockerCommandClient{binary: dockerCLI}.BindHost(receipt.Daemon.Endpoint) + d := &dockerDriver{client: client, daemon: receipt.Daemon} + if err := d.authenticateDaemon(); err != nil { + return err + } + stdout, _, err := d.client.Output("container", "ls", "--all", "--no-trunc", "--filter", "id="+receipt.ContainerID, "--format", "{{.ID}}") + if err != nil || strings.TrimSpace(string(stdout)) != "" { + return errors.New("original contributor absence is not verified") + } + i, err := workflowV4CandidateInspector(p, j.state.Marker.Binding, scopeFile, client, receipt.Daemon) + if err != nil { + return err + } + var chain, signature string + for _, in := range p.Inputs { + if in.Ref == p.Predecessor.Record { + chain = in.Path + } + if in.Ref == p.Predecessor.Signature { + signature = in.Path + } + } + if chain == "" || signature == "" { + return errors.New("exact retained predecessor missing") + } + generated, err := i.ComputationOutputV4(chain, signature, scopeFile, candidate, p.Scope, p.Predecessor) + if err != nil { + return err + } + facts.Scope = p.Scope + facts.CandidateAttemptID = p.AttemptID + facts.GeneratedOutput = &generated + if p.Kind == "attest-erasure" { + inventory, err := i.ContributionInventoryV4(chain, signature, scopeFile, candidate, p.Scope, p.Predecessor) + if err != nil { + return err + } + facts.CandidateInventory = &inventory + facts.ComputedCandidateID = inventory.ComputedCandidateID + facts.CandidateResultID = inventory.CandidateResultID + if err := verifyWorkflowV4CleanupTime(p, receipt, inventory.Computed.Files[3]); err != nil { + return err + } + } + return nil + } + var err error + if op.Status == "reconciled" { + err = inspect(op.Plan) + } else { + err = j.reconcile(id, inspect) + } + if err != nil { + return storagefirst.LocalTurnV4{}, err + } + return facts, nil +} + +func verifyWorkflowV4CleanupTime(p workflowV4OperationPlan, r dockerLifecycleReceipt, ref transcript.ArtifactRef) error { + var expected string + for n, arg := range p.Command { + if arg == "--destroyed-at" && n+1 < len(p.Command) { + expected = p.Command[n+1] + } + } + if expected == "" || expected != r.ErasureDestroyedAt || ref.Name != "erasure.json" || len(p.Outputs) != 2 { + return errors.New("cleanup time differs from the saved operation") + } + raw, err := readTesseraRegularFile(p.Outputs[0], 16<<20, false) + if err != nil { + return err + } + sum := sha256.Sum256(raw) + if int64(len(raw)) != ref.Digest.Size || fmt.Sprintf("sha256:%x", sum) != ref.Digest.SHA256 { + return errors.New("verified cleanup record changed") + } + var record struct { + DestroyedAt string `json:"destroyed_at"` + } + if err := json.Unmarshal(raw, &record); err != nil { + return err + } + if record.DestroyedAt != expected { + return errors.New("signed cleanup record has another destruction time") + } + return nil +} diff --git a/cmd/relay/workflow_v4_reconcile_test.go b/cmd/relay/workflow_v4_reconcile_test.go new file mode 100644 index 0000000..f056e75 --- /dev/null +++ b/cmd/relay/workflow_v4_reconcile_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWorkflowV4CleanupReconciliationBindsSignedTime(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "erasure.json") + raw := `{"destroyed_at":"2026-09-16T00:00:00Z"}` + if err := os.WriteFile(path, []byte(raw), 0644); err != nil { + t.Fatal(err) + } + p := workflowV4OperationPlan{Command: []string{"mpc-ceremony", "phase1", "attest-erasure", "--destroyed-at", "2026-09-16T00:00:00Z"}, Outputs: []string{path, filepath.Join(root, "erasure.sig")}} + r := dockerLifecycleReceipt{ErasureDestroyedAt: "2026-09-16T00:00:00Z"} + ref := workflowV4TestRef("erasure.json", raw) + if err := verifyWorkflowV4CleanupTime(p, r, ref); err != nil { + t.Fatal(err) + } + p.Command[4] = "2026-09-16T00:00:01Z" + r.ErasureDestroyedAt = p.Command[4] + if err := verifyWorkflowV4CleanupTime(p, r, ref); err == nil { + t.Fatal("another signed cleanup time accepted") + } + p.Command[4] = "2026-09-16T00:00:00Z" + r.ErasureDestroyedAt = p.Command[4] + if err := os.WriteFile(path, []byte(raw+"\n"), 0644); err != nil { + t.Fatal(err) + } + if err := verifyWorkflowV4CleanupTime(p, r, ref); err == nil { + t.Fatal("changed verified bytes accepted") + } +} + +func TestWorkflowV4ReconciledOperationIsReinspected(t *testing.T) { + protocol, b := workflowV4TestBinding(t) + p := workflowV4TestPlan(t, b) + j, err := openWorkflowV4Journal(protocol, b.Definition, b) + if err != nil { + t.Fatal(err) + } + defer j.close() + if err := j.prepare(p); err != nil { + t.Fatal(err) + } + if err := j.runPrepared(p.ID, func(workflowV4OperationPlan) error { return nil }); err != nil { + t.Fatal(err) + } + if err := j.reconcile(p.ID, func(workflowV4OperationPlan) error { return nil }); err != nil { + t.Fatal(err) + } + if err := j.close(); err != nil { + t.Fatal(err) + } + j, err = openWorkflowV4Journal(protocol, b.Definition, b) + if err != nil { + t.Fatal(err) + } + defer j.close() + // A saved completion is only a locator. Reopening must inspect its actual + // retained output and report its absence, not trust the completion marker + // or reject inspection merely because there is no pending operation. + _, err = j.reconcileCandidateOperation(p.ID, filepath.Join(b.Work, "scope.json"), "/nonexistent/docker") + if err == nil || !strings.Contains(err.Error(), dockerLifecycleLogName) { + t.Fatalf("did not inspect completed operation: %v", err) + } + if j.state.Operations[0].Status != "reconciled" { + t.Fatal("read-only inspection changed saved operation") + } +} diff --git a/cmd/relay/workflow_v4_release_signer.go b/cmd/relay/workflow_v4_release_signer.go new file mode 100644 index 0000000..60940f7 --- /dev/null +++ b/cmd/relay/workflow_v4_release_signer.go @@ -0,0 +1,268 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +const ( + workflowV4ReleasePackageDir = "release-package" + workflowV4ReleaseTranscriptFile = "setup-transcript.json" + workflowV4ReleaseChecksumsFile = "checksums.sha256" + workflowV4ReleaseManifestPublicFile = "manifest-public-key.hex" +) + +type workflowV4ReleaseSignerProgress struct { + PackageReady bool + PackageDir string + ReviewReport string +} + +func workflowV4ReleaseSignerProgressFor(work string) (workflowV4ReleaseSignerProgress, error) { + base := filepath.Join(work, "workflow-v4", "release") + p := workflowV4ReleaseSignerProgress{PackageDir: filepath.Join(base, workflowV4ReleasePackageDir), ReviewReport: filepath.Join(base, "review.json")} + info, err := os.Lstat(p.PackageDir) + if errors.Is(err, os.ErrNotExist) { + return p, nil + } + if err != nil { + return p, err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return p, errors.New("retained release package path is not a real directory") + } + for _, name := range []string{"manifest.json", "manifest.sig", workflowV4ReleaseManifestPublicFile, workflowV4ReleaseTranscriptFile, workflowV4ReleaseChecksumsFile} { + if !regularPreparationFile(filepath.Join(p.PackageDir, name)) { + return p, errors.New("retained release package is incomplete; preserve it for inspection") + } + } + p.PackageReady = true + return p, nil +} + +func runWorkflowV4ReleaseSignerAction(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, config access.StorageConfig, online, signer guidedProfile, identity setupIdentity, progress workflowV4ReleaseSignerProgress) error { + stateView, err := snapshot.State() + if err != nil { + return err + } + if stateView.Progress.ReleaseReview == nil || stateView.Progress.FinalRelease != nil { + return errors.New("release signing requires the current frozen review and no recorded final release") + } + expected, err := workflowV4ExpectedEnrollment(protocol, identity.ID) + if err != nil { + return err + } + if expected.Role != "release-signer" || expected.Identity.KeyID != identity.KeyID { + return errors.New("local release signer differs from the signed ceremony assignment") + } + if !progress.PackageReady { + return runWorkflowV4ReleaseSigning(ui, snapshot, online, signer, expected, progress) + } + return runWorkflowV4ReleaseUpload(ui, snapshot, protocol, config, online, identity.ID, expected.Identity.KeyID, progress) +} + +func runWorkflowV4ReleaseSigning(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, online, signer guidedProfile, expected transcript.ExpectedEnrollment, progress workflowV4ReleaseSignerProgress) error { + stateView, err := snapshot.State() + if err != nil { + return err + } + head := stateView.Progress.ReleaseReview + if head == nil { + return errors.New("release review is not present") + } + if err := os.MkdirAll(filepath.Dir(progress.ReviewReport), 0o700); err != nil { + return err + } + releasedAt := time.Now().UTC() + if regularPreparationFile(progress.ReviewReport) { + raw, err := readTesseraRegularFile(progress.ReviewReport, 64<<20, false) + if err != nil { + return err + } + var retained struct { + ReleasedAt string `json:"released_at"` + } + if err := json.Unmarshal(raw, &retained); err != nil { + return errors.New("retained release review report is invalid; preserve it for inspection") + } + releasedAt, err = time.Parse(time.RFC3339Nano, retained.ReleasedAt) + if err != nil || releasedAt.Location() != time.UTC { + return errors.New("retained release review time is invalid; preserve it for inspection") + } + } else { + command, err := workflowV4ReleaseReviewCommand(online, signer, *head, progress.ReviewReport, releasedAt) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(online, command, false); err != nil { + return err + } + } + 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) + if err != nil { + return err + } + if err := runWorkflowV4ProfileCommand(signer, command, false); err != nil { + return err + } + fmt.Fprintf(ui.output, "Signed release package created and self-verified at %s. It is still private and has not been accepted or published.\n", progress.PackageDir) + return 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") } + ceremony, err := mapWork(filepath.Join(root, "ceremony.json")) + if err != nil { + return nil, err + } + ceremonySig, _ := mapWork(filepath.Join(root, "ceremony.sig")) + artifactRoot, _ := mapWork(root) + checkpoint, err := mapWork(filepath.Join(root, filepath.FromSlash(head.Record.Name))) + if err != nil { + return nil, err + } + checkpointSig, _ := mapWork(filepath.Join(root, filepath.FromSlash(head.Signature.Name))) + bundle, _ := mapWork(filepath.Join(root, "operational", "evidence-bundle.json")) + bundleSig, _ := mapWork(filepath.Join(root, "operational", "evidence-bundle.sig")) + output, err := mapWork(out) + if err != nil { + return nil, err + } + coordinatorKey, err := pathWithin(online.Trust, filepath.Join(online.Trust, "coordinator-public-key.hex"), "/trust") + if err != nil { + return nil, err + } + return []string{"mpc-ceremony", "release", "review-v4", "--ceremony", ceremony, "--ceremony-signature", ceremonySig, "--coordinator-public-key-file", coordinatorKey, "--artifact-root", artifactRoot, "--checkpoint", checkpoint, "--checkpoint-signature", checkpointSig, "--operational-bundle", bundle, "--operational-bundle-signature", bundleSig, "--released-at", releasedAt.Format(time.RFC3339Nano), "--out", output}, nil +} + +func workflowV4ReleaseSignCommand(online, signer guidedProfile, head transcript.SignedArtifactRefs, releaseDir, keyID string, releasedAt time.Time) ([]string, error) { + root := filepath.Join(online.Work, "ceremony", "public") + mapSignerWork := func(path string) (string, error) { return pathWithin(signer.Work, path, "/work") } + ceremony, err := mapSignerWork(filepath.Join(root, "ceremony.json")) + if err != nil { + return nil, err + } + ceremonySig, _ := mapSignerWork(filepath.Join(root, "ceremony.sig")) + artifactRoot, _ := mapSignerWork(root) + checkpoint, err := mapSignerWork(filepath.Join(root, filepath.FromSlash(head.Record.Name))) + if err != nil { + return nil, err + } + checkpointSig, _ := mapSignerWork(filepath.Join(root, filepath.FromSlash(head.Signature.Name))) + bundle, _ := mapSignerWork(filepath.Join(root, "operational", "evidence-bundle.json")) + bundleSig, _ := mapSignerWork(filepath.Join(root, "operational", "evidence-bundle.sig")) + out, err := mapSignerWork(releaseDir) + if err != nil { + return nil, err + } + coordinatorKey, err := pathWithin(signer.Trust, filepath.Join(signer.Trust, "coordinator-public-key.hex"), "/trust") + if err != nil { + return nil, err + } + return []string{"mpc-ceremony", "release", "sign", "--ceremony", ceremony, "--ceremony-signature", ceremonySig, "--coordinator-public-key-file", coordinatorKey, "--review-checkpoint", checkpoint, "--review-checkpoint-signature", checkpointSig, "--operational-evidence-root", artifactRoot, "--operational-bundle", bundle, "--operational-bundle-signature", bundleSig, "--release-signing-key", "/keys/signing.hex", "--signature-key-id", keyID, "--released-at", releasedAt.Format(time.RFC3339Nano), "--release-dir", out}, nil +} + +func runWorkflowV4ReleaseUpload(ui *coordinatorWizard, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, config access.StorageConfig, online guidedProfile, identity, keyID string, progress workflowV4ReleaseSignerProgress) error { + // The signing command self-verifies, but a retained package may have been + // changed between invocations. Re-authenticate all bytes immediately before + // granting them transport significance. + if err := runWorkflowV4VerifyReleasePackage(online, progress.PackageDir, keyID); err != nil { + return fmt.Errorf("verify retained signed release package: %w", err) + } + grantPath, err := ui.required("Absolute path to the private release upload grant received from the coordinator or Tessera", "") + if err != nil { + return err + } + if !filepath.IsAbs(grantPath) || filepath.Clean(grantPath) != grantPath { + return errors.New("private release grant path must be absolute and clean") + } + grant, err := loadStorageFirstGrant(grantPath) + if err != nil { + return err + } + destination := storagefirst.GrantDestination{Provider: config.Provider, Endpoint: config.Endpoint, Region: config.Region, InboxBucket: config.InboxBucket} + if err := storagefirst.ValidateReleaseGrantV4At(snapshot, protocol, identity, grant, destination, time.Now().UTC()); err != nil { + return err + } + inventory, sources, paths, err := workflowV4ReleaseFiles(progress.PackageDir) + if err != nil { + return err + } + temporary := filepath.Join(filepath.Dir(progress.PackageDir), "temporary") + if err := os.MkdirAll(temporary, 0o700); err != nil { + return err + } + if err := ui.confirm("Upload this complete signed package to the private inbox; upload alone is not coordinator acceptance or public release", "UPLOAD RELEASE PACKAGE"); err != nil { + return err + } + scope := storagefirst.DeliveryScope{CeremonyID: grant.CeremonyID, AttemptID: grant.AttemptID, Kind: access.SubmissionKindRelease} + if err := storagefirst.UploadDelivery(storageFirstGrantClient(grant), scope, inventory, sources, paths, temporary); err != nil { + return err + } + fmt.Fprintln(ui.output, "Signed release package upload completed. Wait for the coordinator to verify it and publish a signed final-release checkpoint.") + return nil +} + +func workflowV4ReleaseFiles(root string) (storagefirst.DeliveryInventory, map[string]state.ContentRef, map[string]string, error) { + inventory := storagefirst.DeliveryInventory{} + sources := map[string]state.ContentRef{} + paths := map[string]string{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == root { + return nil + } + if entry.Type()&os.ModeSymlink != 0 { + return errors.New("release package cannot contain symbolic links") + } + if entry.IsDir() { + return nil + } + if !entry.Type().IsRegular() { + return errors.New("release package contains a non-regular file") + } + name, err := filepath.Rel(root, path) + if err != nil { + return err + } + name = filepath.ToSlash(name) + if strings.HasPrefix(name, "../") || len(inventory) >= 2048 { + return errors.New("release package inventory is invalid or too large") + } + ref, err := workflowV4LocalRef(name, path) + if err != nil { + return err + } + if ref.Digest.Size <= 0 || ref.Digest.Size > 16<<30 { + return errors.New("release package file is empty or exceeds 16 GiB") + } + inventory[name] = ref.Digest.Size + sources[name] = state.ContentRef{Name: name, SHA256: ref.Digest.SHA256, Size: ref.Digest.Size} + paths[name] = path + return nil + }) + if err != nil { + return nil, nil, nil, err + } + if len(inventory) == 0 { + return nil, nil, nil, errors.New("release package is empty") + } + return inventory, sources, paths, nil +} diff --git a/cmd/relay/workflow_v4_release_signer_test.go b/cmd/relay/workflow_v4_release_signer_test.go new file mode 100644 index 0000000..8feadab --- /dev/null +++ b/cmd/relay/workflow_v4_release_signer_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWorkflowV4ReleaseCommandsBindFrozenReviewAndSeparateOutput(t *testing.T) { + work, trust, keys := t.TempDir(), t.TempDir(), t.TempDir() + online := guidedProfile{Work: work, Trust: trust, Keys: keys} + signer := online + head := pairV4Test("final/review") + when := time.Date(2026, 9, 16, 6, 7, 8, 9, time.UTC) + report := filepath.Join(work, "workflow-v4", "release", "review.json") + packageDir := filepath.Join(work, "workflow-v4", "release", "release-package") + review, err := workflowV4ReleaseReviewCommand(online, signer, head, report, when) + if err != nil { + t.Fatal(err) + } + sign, err := workflowV4ReleaseSignCommand(online, signer, head, packageDir, "release-key", when) + if err != nil { + t.Fatal(err) + } + for command, wants := range map[string][]string{ + strings.Join(review, " "): {"release review-v4", "--checkpoint /work/ceremony/public/" + head.Record.Name, "--released-at " + when.Format(time.RFC3339Nano), "--out /work/workflow-v4/release/review.json"}, + strings.Join(sign, " "): {"release sign", "--review-checkpoint /work/ceremony/public/" + head.Record.Name, "--operational-evidence-root /work/ceremony/public", "--release-signing-key /keys/signing.hex", "--signature-key-id release-key", "--release-dir /work/workflow-v4/release/release-package"}, + } { + for _, want := range wants { + if !strings.Contains(command, want) { + t.Fatalf("command %q lacks %q", command, want) + } + } + } +} + +func TestWorkflowV4ReleaseFilesPreserveNestedNamesAndRejectSymlink(t *testing.T) { + root := t.TempDir() + for name, body := range map[string]string{"manifest.json": "manifest", "operational/evidence.json": "evidence"} { + path := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + inventory, _, paths, err := workflowV4ReleaseFiles(root) + if err != nil { + t.Fatal(err) + } + if len(inventory) != 2 || paths["operational/evidence.json"] == "" { + t.Fatalf("nested release inventory = %#v", inventory) + } + if err := os.Symlink(filepath.Join(root, "manifest.json"), filepath.Join(root, "link")); err != nil { + t.Fatal(err) + } + if _, _, _, err := workflowV4ReleaseFiles(root); err == nil { + t.Fatal("release package symlink accepted") + } +} + +func TestWorkflowV4FinalReleaseCheckpointUsesOnlyProtocolBootstraps(t *testing.T) { + paths := map[string]string{ + "manifest.json": "/release/manifest.json", + "manifest.sig": "/release/manifest.sig", + "setup-transcript.json": "/release/setup-transcript.json", + "manifest-public-key.hex": "/release/manifest-public-key.hex", + "checksums.sha256": "/release/checksums.sha256", + "ownership.pk": "/release/ownership.pk", + } + evidence, err := workflowV4FinalReleaseEvidence(paths) + if err != nil { + t.Fatal(err) + } + want := []string{"/release/checksums.sha256", "/release/manifest-public-key.hex", "/release/setup-transcript.json"} + if strings.Join(evidence, "\n") != strings.Join(want, "\n") { + t.Fatalf("checkpoint evidence = %q, want %q", evidence, want) + } + delete(paths, "setup-transcript.json") + if _, err := workflowV4FinalReleaseEvidence(paths); err == nil { + t.Fatal("missing final release bootstrap accepted") + } +} + +func TestWorkflowV4ReleaseSignerRecognizesProofToolPackageLayout(t *testing.T) { + work := t.TempDir() + root := filepath.Join(work, "workflow-v4", "release", workflowV4ReleasePackageDir) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"manifest.json", "manifest.sig", workflowV4ReleaseManifestPublicFile, workflowV4ReleaseTranscriptFile, workflowV4ReleaseChecksumsFile} { + if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0o600); err != nil { + t.Fatal(err) + } + } + progress, err := workflowV4ReleaseSignerProgressFor(work) + if err != nil { + t.Fatal(err) + } + if !progress.PackageReady { + t.Fatal("complete proof-tool release package was not recognized") + } +} diff --git a/cmd/relay/workflow_v4_sync.go b/cmd/relay/workflow_v4_sync.go new file mode 100644 index 0000000..d9a87a6 --- /dev/null +++ b/cmd/relay/workflow_v4_sync.go @@ -0,0 +1,74 @@ +package main + +import ( + "errors" + "path/filepath" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +// syncV4 is called with the role workspace lock held. The caller supplies the +// configured storage client, never a destination discovered in an artifact. +// Each verification mounts only the temporary public metadata root created by +// SyncV4; the normal inspector's fixed transcript mount cannot cover that root. +func (j *workflowV4Journal) syncV4(objects storagefirst.ObjectStore, trust transcript.Inspector, dockerCLI string) (storagefirst.SnapshotV4, error) { + var zero storagefirst.SnapshotV4 + if objects == nil { + return zero, errors.New("configured storage required") + } + if j.lock == nil || j.writeErr != nil { + return zero, errors.New("active healthy V4 workspace required") + } + if !filepath.IsAbs(dockerCLI) || filepath.Clean(dockerCLI) != dockerCLI { + return zero, errors.New("exact local Docker CLI path required") + } + b := j.state.Marker.Binding + r := b.Runtimes["online"] + if err := validateWorkflowV4Runtime(r, b.Work); err != nil { + return zero, err + } + if _, err := pathWithin(r.Mounts["/trust"], trust.CoordinatorPublicKeyPath, "/trust"); err != nil { + return zero, err + } + if err := workflowV4InputsMatch(workflowV4OperationPlan{Runtime: r, Inputs: []workflowV4Input{{Path: trust.CeremonyPath, Ref: b.Definition.Record}, {Path: trust.CeremonySignaturePath, Ref: b.Definition.Signature}}}); err != nil { + return zero, err + } + d := dockerDriver{image: r.Image, platform: r.Platform, definition: trust.CeremonyPath, definitionSig: trust.CeremonySignaturePath, coordinatorKey: trust.CoordinatorPublicKeyPath, client: osDockerCommandClient{binary: dockerCLI}} + if err := d.authenticateDaemon(); err != nil { + return zero, err + } + if err := prepareGuidedImage(r.Image, r.Platform, dockerCLI, false); err != nil { + return zero, err + } + trust.Runner = func(executable string, args ...string) ([]byte, []byte, error) { + if executable != "mpc-ceremony" && executable != "/usr/local/bin/mpc-ceremony" { + return nil, nil, errors.New("unexpected V4 metadata executable") + } + if len(args) < 4 || args[0] != "--format" || args[1] != "json" || args[2] != "checkpoint" || (args[3] != "inspect-signed-v4" && args[3] != "inspect-enrollments-v4") { + return nil, nil, errors.New("metadata synchronization cannot execute another command") + } + root := commandValue(args, "artifact-root") + if _, err := pathWithin(b.Work, root, "/work"); err != nil { + return nil, nil, err + } + if err := validateCommitLocalPath(root); err != nil { + return nil, nil, err + } + child := d + child.root = root + rewritten, mounts, err := child.rewriteReadOnlyArgs(args) + if err != nil { + return nil, nil, err + } + command := append(child.baseRunArgs(true, mounts), child.image) + return child.client.Output(append(command, rewritten...)...) + } + trust.Executable = "mpc-ceremony" + highWater, err := state.OpenWorkspaceHighWater(b.Work, b.CeremonyID) + if err != nil { + return zero, err + } + return storagefirst.SyncV4Retained(objects, trust, highWater, b.CeremonyID, b.Work, filepath.Join(b.Work, "ceremony", "public")) +} diff --git a/cmd/relay/workflow_v4_sync_test.go b/cmd/relay/workflow_v4_sync_test.go new file mode 100644 index 0000000..5a3c1a2 --- /dev/null +++ b/cmd/relay/workflow_v4_sync_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +type forbiddenSyncStoreV4 struct{ t *testing.T } + +func (s forbiddenSyncStoreV4) GetVersionedAtMost(string, string, int64) (store.ObjectVersion, error) { + s.t.Error("storage contacted before validating local trust") + return store.ObjectVersion{}, errors.New("unexpected storage request") +} + +func TestWorkflowV4SyncRejectsWrongLocalTrustBeforeExecution(t *testing.T) { + protocol, b := workflowV4TestBinding(t) + p := workflowV4TestPlan(t, b) + j, err := openWorkflowV4Journal(protocol, b.Definition, b) + if err != nil { + t.Fatal(err) + } + defer j.close() + i := transcript.Inspector{CeremonyPath: p.Inputs[2].Path, CeremonySignaturePath: p.Inputs[3].Path, CoordinatorPublicKeyPath: p.Inputs[4].Path} + wrong := i + wrong.CeremonyPath = p.Inputs[5].Path + if _, err := j.syncV4(forbiddenSyncStoreV4{t}, wrong, "/nonexistent/docker"); err == nil { + t.Fatal("accepted another definition") + } + wrong = i + wrong.CoordinatorPublicKeyPath = filepath.Join(b.Work, "downloaded-key.hex") + if _, err := j.syncV4(forbiddenSyncStoreV4{t}, wrong, "/nonexistent/docker"); err == nil { + t.Fatal("accepted key outside trust folder") + } + if _, err := j.syncV4(forbiddenSyncStoreV4{t}, i, "/nonexistent/docker"); err == nil || strings.Contains(err.Error(), "outside retained public mounts") { + t.Fatalf("valid retained definition did not reach Docker authentication: %v", err) + } + if err := j.close(); err != nil { + t.Fatal(err) + } + if _, err := j.syncV4(forbiddenSyncStoreV4{t}, i, "/nonexistent/docker"); err == nil { + t.Fatal("synced without workspace lock") + } +} diff --git a/cmd/relay/workflow_v4_upload.go b/cmd/relay/workflow_v4_upload.go new file mode 100644 index 0000000..3ee797f --- /dev/null +++ b/cmd/relay/workflow_v4_upload.go @@ -0,0 +1,271 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/transcript" +) + +const workflowV4UploadRecordSchema = "relay-workflow-v4-upload-v1" + +type workflowV4UploadRecord struct { + Schema string `json:"schema"` + Scope transcript.ContributionScopeV4 `json:"scope"` + AttemptID string `json:"attempt_id"` + CandidateResultID string `json:"candidate_result_id"` + ManifestKey string `json:"manifest_key"` + UploadedAt string `json:"uploaded_at"` +} + +func workflowV4CandidateDeliveryInventory() storagefirst.DeliveryInventory { + return storagefirst.DeliveryInventory{"attestation.json": 16 << 20, "attestation.sig": 4096, "contribution.bin": 16 << 30, "erasure.json": 16 << 20, "erasure.sig": 4096} +} + +func workflowV4CandidateFiles(candidateDir string, inventory transcript.ContributionInventoryFactsV4) (map[string]state.ContentRef, map[string]string, error) { + if inventory.Complete == nil || inventory.CandidateResultID == "" || inventory.ComputedCandidateID == "" || inventory.ComputedCandidateID != inventory.CandidateResultID || inventory.Scope != inventory.Complete.Scope || len(inventory.Complete.Files) != 5 { + return nil, nil, errors.New("complete verified five-file candidate inventory required") + } + limits := workflowV4CandidateDeliveryInventory() + sources := make(map[string]state.ContentRef, len(limits)) + paths := make(map[string]string, len(limits)) + for _, ref := range inventory.Complete.Files { + limit, ok := limits[ref.Name] + if !ok || ref.Digest.Size <= 0 || ref.Digest.Size > limit || ref.Digest.SHA256 == "" || ref.Digest.Blake2b256 == "" { + return nil, nil, errors.New("verified candidate inventory contains an unexpected file") + } + if _, duplicate := sources[ref.Name]; duplicate { + return nil, nil, errors.New("verified candidate inventory repeats a file") + } + sources[ref.Name] = state.ContentRef{Name: ref.Name, SHA256: ref.Digest.SHA256, Size: ref.Digest.Size} + paths[ref.Name] = filepath.Join(candidateDir, ref.Name) + } + if len(sources) != len(limits) { + return nil, nil, errors.New("verified candidate inventory is incomplete") + } + return sources, paths, nil +} + +func workflowV4CandidateDir(plan workflowV4OperationPlan) (string, error) { + for _, input := range plan.Inputs { + if input.Ref.Name == "contribution.bin" { + return filepath.Dir(input.Path), nil + } + } + return "", errors.New("retained upload plan has no candidate directory") +} + +func (j *workflowV4Journal) prepareWorkflowV4Upload(cleanupID, activeAttemptID string, inventory transcript.ContributionInventoryFactsV4) (workflowV4OperationPlan, error) { + var zero workflowV4OperationPlan + if j.lock == nil || j.writeErr != nil { + return zero, errors.New("active V4 workspace required") + } + var cleanup *workflowV4Operation + for n := range j.state.Operations { + if j.state.Operations[n].Plan.ID == cleanupID { + cleanup = &j.state.Operations[n] + break + } + } + if cleanup == nil || cleanup.Status != "reconciled" || cleanup.Plan.Kind != "attest-erasure" || inventory.Scope != cleanup.Plan.Scope || inventory.Predecessor != cleanup.Plan.Predecessor { + return zero, errors.New("reconciled cleanup and matching verified candidate inventory required") + } + if !validFlowAttemptID(activeAttemptID) { + return zero, errors.New("active authenticated candidate attempt required") + } + if activeAttemptID != cleanup.Plan.AttemptID { + return zero, errors.New("retained candidate belongs to a retired allocation; make a fresh contribution for the replacement attempt") + } + candidateDir := filepath.Dir(cleanup.Plan.Outputs[0]) + sources, paths, err := workflowV4CandidateFiles(candidateDir, inventory) + if err != nil { + return zero, err + } + inputs := make([]workflowV4Input, 0, 7) + for _, input := range cleanup.Plan.Inputs { + if input.Ref == cleanup.Plan.Predecessor.Record || input.Ref == cleanup.Plan.Predecessor.Signature { + inputs = append(inputs, input) + } + } + for _, ref := range inventory.Complete.Files { + content := sources[ref.Name] + if ref.Digest.SHA256 != content.SHA256 || ref.Digest.Size != content.Size || ref.Digest.Blake2b256 == "" { + return zero, errors.New("verified candidate inventory digest differs from upload source") + } + inputs = append(inputs, workflowV4Input{Path: paths[ref.Name], Ref: ref}) + } + id, err := randomID() + if err != nil { + return zero, err + } + marker := filepath.Join(j.state.Marker.Binding.Work, "workflow-v4", "results", id+".json") + // The candidate was computed under this exact allocation. A replacement + // allocation must produce a fresh candidate because proof-tool verifies that + // allocation predates its contribution. + plan := workflowV4OperationPlan{ID: id, Kind: "upload-candidate", Scope: cleanup.Plan.Scope, Predecessor: cleanup.Plan.Predecessor, AttemptID: activeAttemptID, Runtime: j.state.Marker.Binding.Runtimes["online"], Command: []string{"relay-internal", "upload-candidate"}, Inputs: inputs, Outputs: []string{marker}} + if err := validateWorkflowV4Plan(plan, j.state.Marker.Binding); err != nil { + return zero, err + } + return plan, nil +} + +func (j *workflowV4Journal) executePreparedCandidateUpload(id string, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, grant access.StorageFirstGrant, destination storagefirst.GrantDestination, inventory transcript.ContributionInventoryFactsV4, now time.Time) error { + op, err := j.pending() + if err != nil { + return err + } + if op == nil || op.Plan.ID != id || op.Plan.Kind != "upload-candidate" || op.Status != "prepared" || inventory.Scope != op.Plan.Scope || inventory.Predecessor != op.Plan.Predecessor || inventory.CandidateResultID == "" { + return errors.New("prepared candidate upload and verified inventory required") + } + if err := storagefirst.ValidateGrantV4At(snapshot, protocol, j.state.Marker.Binding.IdentityID, grant, destination, now); err != nil { + return err + } + if grant.AttemptID != op.Plan.AttemptID { + return errors.New("upload grant differs from the retained candidate attempt") + } + candidateDir, err := workflowV4CandidateDir(op.Plan) + if err != nil { + return err + } + sources, paths, err := workflowV4CandidateFiles(candidateDir, inventory) + if err != nil { + return err + } + temporary := filepath.Join(j.state.Marker.Binding.Work, "workflow-v4", "temporary") + if err := ensureWorkflowV4Directory(j.state.Marker.Binding.Work, temporary); err != nil { + return err + } + scope := storagefirst.DeliveryScope{CeremonyID: op.Plan.Scope.CeremonyID, AttemptID: op.Plan.AttemptID, Kind: access.SubmissionKindCandidate} + return j.runPrepared(id, func(plan workflowV4OperationPlan) error { + return j.uploadCandidatePlan(plan, storageFirstGrantClient(grant), scope, sources, paths, inventory.CandidateResultID, grant.ManifestKey, temporary, now) + }) +} + +// resumeCandidateUpload is the only running operation that may repeat a remote +// mutation. Candidate objects are immutable create-only writes; every existing +// object is compared with the retained bytes and the manifest is always last. +// Contribution and signing operations deliberately have no equivalent retry. +func (j *workflowV4Journal) resumeCandidateUpload(id string, snapshot storagefirst.SnapshotV4, protocol transcript.DefinitionProtocol, grant access.StorageFirstGrant, destination storagefirst.GrantDestination, inventory transcript.ContributionInventoryFactsV4, now time.Time) error { + op, err := j.pending() + if err != nil { + return err + } + if op == nil || op.Plan.ID != id || op.Plan.Kind != "upload-candidate" || op.Status != "running" || inventory.Scope != op.Plan.Scope || inventory.Predecessor != op.Plan.Predecessor || inventory.CandidateResultID == "" { + return errors.New("uncertain exact candidate upload and verified inventory required") + } + if err := storagefirst.ValidateGrantV4At(snapshot, protocol, j.state.Marker.Binding.IdentityID, grant, destination, now); err != nil { + return err + } + if grant.AttemptID != op.Plan.AttemptID { + return errors.New("replacement grant differs from the retained upload attempt") + } + candidateDir, err := workflowV4CandidateDir(op.Plan) + if err != nil { + return err + } + sources, paths, err := workflowV4CandidateFiles(candidateDir, inventory) + if err != nil { + return err + } + if err := workflowV4InputsMatch(op.Plan); err != nil { + return err + } + temporary := filepath.Join(j.state.Marker.Binding.Work, "workflow-v4", "temporary") + if err := ensureWorkflowV4Directory(j.state.Marker.Binding.Work, temporary); err != nil { + return err + } + scope := storagefirst.DeliveryScope{CeremonyID: op.Plan.Scope.CeremonyID, AttemptID: op.Plan.AttemptID, Kind: access.SubmissionKindCandidate} + if err := j.uploadCandidatePlan(op.Plan, storageFirstGrantClient(grant), scope, sources, paths, inventory.CandidateResultID, grant.ManifestKey, temporary, now); err != nil { + return err + } + return j.transition(id, "returned-needs-verification") +} + +func (j *workflowV4Journal) uploadCandidatePlan(plan workflowV4OperationPlan, objects storagefirst.ImmutableStore, scope storagefirst.DeliveryScope, sources map[string]state.ContentRef, paths map[string]string, resultID, manifestKey, temporary string, now time.Time) error { + if err := storagefirst.UploadDelivery(objects, scope, workflowV4CandidateDeliveryInventory(), sources, paths, temporary); err != nil { + return err + } + if err := ensureWorkflowV4Directory(j.state.Marker.Binding.Work, filepath.Dir(plan.Outputs[0])); err != nil { + return err + } + record := workflowV4UploadRecord{Schema: workflowV4UploadRecordSchema, Scope: plan.Scope, AttemptID: plan.AttemptID, CandidateResultID: resultID, ManifestKey: manifestKey, UploadedAt: now.UTC().Truncate(time.Second).Format(time.RFC3339)} + if err := writeJSONNoReplace(plan.Outputs[0], record, 0600); err != nil { + if !errors.Is(err, os.ErrExist) { + return fmt.Errorf("save verified upload result: %w", err) + } + var existing workflowV4UploadRecord + if readErr := readWorkflowV4JSON(plan.Outputs[0], &existing); readErr != nil || !sameWorkflowV4Upload(existing, record) { + return errors.New("existing upload result differs from the exact resumed candidate") + } + } + return nil +} + +func sameWorkflowV4Upload(a, b workflowV4UploadRecord) bool { + if _, err := time.Parse(time.RFC3339, a.UploadedAt); err != nil { + return false + } + return a.Schema == b.Schema && a.Scope == b.Scope && a.AttemptID == b.AttemptID && a.CandidateResultID == b.CandidateResultID && a.ManifestKey == b.ManifestKey +} + +func (j *workflowV4Journal) reconcileCandidateUpload(id string, objects storagefirst.ObjectStore, inventory transcript.ContributionInventoryFactsV4) error { + return j.reconcile(id, func(plan workflowV4OperationPlan) error { + if plan.Kind != "upload-candidate" || inventory.Scope != plan.Scope || inventory.Predecessor != plan.Predecessor || inventory.CandidateResultID == "" { + return errors.New("retained candidate upload differs from verified inventory") + } + candidateDir, err := workflowV4CandidateDir(plan) + if err != nil { + return err + } + sources, _, err := workflowV4CandidateFiles(candidateDir, inventory) + if err != nil { + return err + } + temporary := filepath.Join(j.state.Marker.Binding.Work, "workflow-v4", "temporary") + if err := ensureWorkflowV4Directory(j.state.Marker.Binding.Work, temporary); err != nil { + return err + } + scope := storagefirst.DeliveryScope{CeremonyID: plan.Scope.CeremonyID, AttemptID: plan.AttemptID, Kind: access.SubmissionKindCandidate} + download, err := storagefirst.FetchDelivery(objects, scope, workflowV4CandidateDeliveryInventory(), temporary) + if err != nil { + return err + } + defer os.RemoveAll(download) + for name, expected := range sources { + actual, err := regularFileRef(filepath.Join(download, name), name) + if err != nil || actual.SHA256 != expected.SHA256 || actual.Size != expected.Size { + return errors.New("uploaded candidate bytes differ from the retained verified inventory") + } + } + var record workflowV4UploadRecord + if err := readWorkflowV4JSON(plan.Outputs[0], &record); errors.Is(err, os.ErrNotExist) { + prefix, prefixErr := scope.Prefix() + if prefixErr != nil { + return prefixErr + } + record = workflowV4UploadRecord{Schema: workflowV4UploadRecordSchema, Scope: plan.Scope, AttemptID: plan.AttemptID, CandidateResultID: inventory.CandidateResultID, ManifestKey: prefix + "/manifest.json", UploadedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339)} + if err := ensureWorkflowV4Directory(j.state.Marker.Binding.Work, filepath.Dir(plan.Outputs[0])); err != nil { + return err + } + if err := writeJSONNoReplace(plan.Outputs[0], record, 0600); err != nil { + return err + } + } else if err != nil { + return err + } + prefix, err := scope.Prefix() + if err != nil { + return err + } + if record.Schema != workflowV4UploadRecordSchema || record.Scope != plan.Scope || record.AttemptID != plan.AttemptID || record.CandidateResultID != inventory.CandidateResultID || record.ManifestKey != prefix+"/manifest.json" { + return errors.New("retained upload result differs from the exact candidate") + } + return nil + }) +} diff --git a/cmd/relay/workflow_v4_upload_test.go b/cmd/relay/workflow_v4_upload_test.go new file mode 100644 index 0000000..1534d85 --- /dev/null +++ b/cmd/relay/workflow_v4_upload_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/zksecurity/relay/internal/storagefirst" + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +type workflowV4UploadStore struct { + objects map[string][]byte + writes []string + fail string +} + +func (s *workflowV4UploadStore) PutIfAbsent(key, path string) (store.ObjectVersion, error) { + if _, ok := s.objects[key]; ok { + return store.ObjectVersion{}, store.ErrExists + } + raw, err := os.ReadFile(path) + if err != nil { + return store.ObjectVersion{}, err + } + s.objects[key] = raw + s.writes = append(s.writes, key) + if key == s.fail { + return store.ObjectVersion{}, errors.New("lost upload response") + } + return store.ObjectVersion{Size: int64(len(raw))}, nil +} + +func (s *workflowV4UploadStore) GetVersionedAtMost(key, path string, maximum int64) (store.ObjectVersion, error) { + raw, ok := s.objects[key] + if !ok { + return store.ObjectVersion{}, os.ErrNotExist + } + if int64(len(raw)) > maximum { + return store.ObjectVersion{}, errors.New("object exceeds bound") + } + if err := os.WriteFile(path, raw, 0600); err != nil { + return store.ObjectVersion{}, err + } + return store.ObjectVersion{Size: int64(len(raw))}, nil +} + +func workflowV4UploadFixture(t *testing.T) (*workflowV4Journal, workflowV4OperationPlan, transcript.ContributionInventoryFactsV4) { + t.Helper() + protocol, binding := workflowV4TestBinding(t) + j, err := openWorkflowV4Journal(protocol, binding.Definition, binding) + if err != nil { + t.Fatal(err) + } + contribution := workflowV4TestPlan(t, binding) + candidate := contribution.Outputs[0] + if err := os.Mkdir(candidate, 0700); err != nil { + t.Fatal(err) + } + files := make([]transcript.ArtifactRef, 0, 5) + inputs := append([]workflowV4Input(nil), contribution.Inputs...) + for _, name := range []string{"attestation.json", "attestation.sig", "contribution.bin", dockerLifecycleLogName} { + raw := "public " + name + if err := os.WriteFile(filepath.Join(candidate, name), []byte(raw), 0600); err != nil { + t.Fatal(err) + } + ref := workflowV4TestRef(name, raw) + inputs = append(inputs, workflowV4Input{Path: filepath.Join(candidate, name), Ref: ref}) + if name != dockerLifecycleLogName { + files = append(files, ref) + } + } + id := strings.Repeat("3", 32) + root, err := workflowV4ContainerPath(binding.Runtimes["signer"], candidate) + if err != nil { + t.Fatal(err) + } + definition, _ := workflowV4ContainerPath(binding.Runtimes["signer"], contribution.Inputs[4].Path) + definitionSignature, _ := workflowV4ContainerPath(binding.Runtimes["signer"], contribution.Inputs[5].Path) + coordinatorKey, _ := workflowV4ContainerPath(binding.Runtimes["signer"], contribution.Inputs[6].Path) + cleanup := workflowV4OperationPlan{ + ID: id, Kind: "attest-erasure", Scope: contribution.Scope, Predecessor: contribution.Predecessor, + AttemptID: contribution.AttemptID, Runtime: binding.Runtimes["signer"], Inputs: inputs, + Command: []string{"mpc-ceremony", "phase1", "attest-erasure", "--ceremony", definition, "--ceremony-signature", definitionSignature, "--coordinator-public-key-file", coordinatorKey, "--participant-id", contribution.Scope.ParticipantID, "--participant-signing-key", "/keys/signing.hex", "--candidate-dir", root, "--destroyed-at", "2026-09-16T00:00:00Z"}, + Outputs: []string{filepath.Join(candidate, "erasure.json"), filepath.Join(candidate, "erasure.sig")}, + } + if err := j.prepare(cleanup); err != nil { + t.Fatal(err) + } + if err := j.runPrepared(cleanup.ID, func(workflowV4OperationPlan) error { + for _, name := range []string{"erasure.json", "erasure.sig"} { + raw := "public " + name + if err := os.WriteFile(filepath.Join(candidate, name), []byte(raw), 0600); err != nil { + return err + } + files = append(files, workflowV4TestRef(name, raw)) + } + return nil + }); err != nil { + t.Fatal(err) + } + if err := j.reconcile(cleanup.ID, func(workflowV4OperationPlan) error { return nil }); err != nil { + t.Fatal(err) + } + resultID := "sha256:" + strings.Repeat("9", 64) + complete := transcript.CandidateInventoryV4{Schema: "proof-tool-mpc-candidate-inventory-v1", Scope: contribution.Scope, Files: files} + inventory := transcript.ContributionInventoryFactsV4{Scope: contribution.Scope, Predecessor: contribution.Predecessor, Computed: complete, Complete: &complete, ComputedCandidateID: resultID, CandidateResultID: resultID} + return j, cleanup, inventory +} + +func TestWorkflowV4UploadRejectsReplacementAttemptForRetainedCandidate(t *testing.T) { + j, cleanup, inventory := workflowV4UploadFixture(t) + defer j.close() + replacement := strings.Repeat("b", 32) + if _, err := j.prepareWorkflowV4Upload(cleanup.ID, replacement, inventory); err == nil || !strings.Contains(err.Error(), "fresh contribution") { + t.Fatalf("replacement candidate upload = %v, want rejection", err) + } +} + +func TestWorkflowV4UploadExactResumePublishesManifestLast(t *testing.T) { + j, cleanup, inventory := workflowV4UploadFixture(t) + defer j.close() + attempt := cleanup.AttemptID + plan, err := j.prepareWorkflowV4Upload(cleanup.ID, attempt, inventory) + if err != nil { + t.Fatal(err) + } + candidate, err := workflowV4CandidateDir(plan) + if err != nil { + t.Fatal(err) + } + sources, paths, err := workflowV4CandidateFiles(candidate, inventory) + if err != nil { + t.Fatal(err) + } + scope := storagefirst.DeliveryScope{CeremonyID: plan.Scope.CeremonyID, AttemptID: attempt, Kind: "candidate"} + prefix, _ := scope.Prefix() + objects := &workflowV4UploadStore{objects: make(map[string][]byte), fail: prefix + "/files/contribution.bin"} + temporary := filepath.Join(j.state.Marker.Binding.Work, "workflow-v4", "temporary") + if err := ensureWorkflowV4Directory(j.state.Marker.Binding.Work, temporary); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 16, 0, 30, 0, 0, time.UTC) + err = j.uploadCandidatePlan(plan, objects, scope, sources, paths, inventory.CandidateResultID, prefix+"/manifest.json", temporary, now) + if err == nil { + t.Fatal("lost response was hidden") + } + objects.fail = "" + if err := j.uploadCandidatePlan(plan, objects, scope, sources, paths, inventory.CandidateResultID, prefix+"/manifest.json", temporary, now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if got := objects.writes[len(objects.writes)-1]; got != prefix+"/manifest.json" { + t.Fatalf("last upload = %s", got) + } + if err := j.uploadCandidatePlan(plan, objects, scope, sources, paths, inventory.CandidateResultID, prefix+"/manifest.json", temporary, now.Add(2*time.Minute)); err != nil { + t.Fatalf("exact repeated upload failed: %v", err) + } + var record workflowV4UploadRecord + if err := readWorkflowV4JSON(plan.Outputs[0], &record); err != nil || record.CandidateResultID != inventory.CandidateResultID { + t.Fatalf("retained result = %+v, %v", record, err) + } +} diff --git a/contracts/setupv3/README.md b/contracts/setupv3/README.md new file mode 100644 index 0000000..1001942 --- /dev/null +++ b/contracts/setupv3/README.md @@ -0,0 +1,56 @@ +# Shared setup v3 contract + +This contract uses `ceremony-setup-v3` and `two-phase-v3`, version 3. It adds an +explicit assurance policy and makes the beacon lead configurable in rehearsal +and production. Defaults are 180 seconds for rehearsal and 24 hours for +production; another positive value may be signed. Relay must prominently warn +before a coordinator signs a shorter production value. + +Witnesses, mirrors and ceremony audits are independently configurable. Zero +explicitly disables a control. Witness and mirror identities are assigned and +enrolled after initialization; only their counts are signed here. External +security-audit signoffs remain represented in the proof protocol, but this +setup revision requires zero until the website and CLI implement their complete +collection journey. Production still requires two participants per phase and +all scheduled contributions. + +`../setupv2/` and `../setupv2r2/` remain immutable for older ceremonies. Do not +convert or silently upgrade a frozen setup. + +The new proof-tool and CLI release must be published and provisioned before this +revision is available in Tessera. Source changes alone do not update release pins. + +This directory is the source of the public `ceremony-setup-v3` contract used by +the CLI and Tessera. `schema.json`, `ruleset.json`, `beacon.json`, `setup.mjs` and +`fixtures.json` are vendored byte-for-byte into Tessera by its +`scripts/sync-setup-contract.mjs` script. Go embeds the JSON resources here. + +Both implementations reject duplicate keys, invalid Unicode, unknown fields and +versions, excessive nesting, and files larger than 8 MiB. Shared fixtures cover +canonical hashes and acceptance/rejection. Run `go test ./contracts/setupv3` in +this repository and Tessera's matching shared-contract test for the same vectors. + +The website downloads its exact public `setup` object. The CLI retains the plan +and adds only `result`, preserving definition/signature/key/manifest bytes inside +base64 artifacts. Website ownership, account bindings and notification settings +are not part of this object. Phase membership exists only in the ordered phase +lists. Role IDs and signing identity IDs are different kinds of identifiers. + +Hashes use RFC 8785 canonical JSON and these UTF-8 prefixes, including the newline: + +* Input: `ceremony-setup-input-v3\n` plus the object without `result`. +* Result: `ceremony-setup-result-v3\n` plus the result object. +* Complete setup: `ceremony-setup-v3\n` plus the full setup object. + +Artifact hashes cover exact original bytes. Ruleset and workflow-recipe hashes +cover canonical JSON without a prefix. The release manifest's contract hash +covers exact `schema.json` bytes. No digest is embedded in the object it hashes. + +Contract validation does **not** authenticate signatures, release provenance, +ownership, protocol enrollment or progress. Callers must perform the appropriate +additional checks. The synthetic fixtures deliberately contain no real ceremony +signatures and must never be provisioned as trusted release metadata. + +Public artifact base URLs use HTTPS with a DNS hostname, an optional valid port, +and an optional path. IP literals, user information, query strings, fragments, +control characters and malformed percent escapes are outside this v3 profile. diff --git a/contracts/setupv3/beacon.json b/contracts/setupv3/beacon.json new file mode 100644 index 0000000..3205133 --- /dev/null +++ b/contracts/setupv3/beacon.json @@ -0,0 +1,13 @@ +{ + "provider": "drand", + "network": "quicknet-mainnet", + "chain_hash_hex": "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "public_key_hex": "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a", + "scheme": "bls-unchained-g1-rfc9380", + "genesis_time_unix": 1692803367, + "period_seconds": 3, + "extraction": "sha256-domain-separated-length-prefixed-v1", + "minimum_challenge_bytes": 32, + "minimum_witness_lead_seconds": 180, + "future_round_required": true +} diff --git a/contracts/setupv3/fixtures.json b/contracts/setupv3/fixtures.json new file mode 100644 index 0000000..a83fb4c --- /dev/null +++ b/contracts/setupv3/fixtures.json @@ -0,0 +1,24 @@ +[ + { + "name": "valid-zero-assurance-short-rehearsal", + "json": "{\"schema\":\"ceremony-setup-v3\",\"id\":\"10000000-0000-4000-8000-000000000001\",\"plan_revision\":1,\"plan\":{\"mode\":\"rehearsal\",\"ruleset\":{\"id\":\"two-phase-v3\",\"version\":3,\"sha256\":\"sha256:cc54516ad13d9fe58cb1dcfb4da516f8f79dedf0808efc549a0a556b0bc5b2a1\"},\"circuit\":\"rehearsal-tiny-v1\",\"beacon_policy\":{\"chain_hash_hex\":\"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971\",\"extraction\":\"sha256-domain-separated-length-prefixed-v1\",\"future_round_required\":true,\"genesis_time_unix\":1692803367,\"minimum_challenge_bytes\":32,\"minimum_witness_lead_seconds\":12,\"network\":\"quicknet-mainnet\",\"period_seconds\":3,\"provider\":\"drand\",\"public_key_hex\":\"83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a\",\"scheme\":\"bls-unchained-g1-rfc9380\"},\"software_release\":{\"release_tag\":\"role-images-cccccccccccccccccccccccccccccccccccccccc\",\"cli_commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"proof_tool_commit\":\"dddddddddddddddddddddddddddddddddddddddd\",\"manifest_sha256\":\"sha256:118607f7a26232e1458c6cc2419275f4a6cd6a628f2f17e637c36fd7bd9e38a4\",\"workflow_recipe_sha256\":\"sha256:1bf3adcf5c1cdce0385842c8b1ae57860610a4241d419dba145c2349aa563378\"},\"storage\":{\"provider\":\"aws\",\"region\":\"us-east-1\",\"public_base_url\":\"https://ceremony.example.test\",\"published_bucket\":\"ceremony-published-test\",\"inbox_bucket\":\"ceremony-inbox-test\"},\"assurance_policy\":{\"public_witnesses_per_phase\":0,\"mirrors_per_accepted_head\":0,\"passing_ceremony_audits\":0,\"external_security_audit_signoffs\":0},\"identities\":[{\"id\":\"coordinator-1\",\"display_name\":\"Coordinator\",\"key_id\":\"key-1\",\"ed25519_public_key_hex\":\"0101010101010101010101010101010101010101010101010101010101010101\",\"public_key_fingerprint\":\"sha256:72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793\"},{\"id\":\"signer-1\",\"display_name\":\"Signer\",\"key_id\":\"key-2\",\"ed25519_public_key_hex\":\"0202020202020202020202020202020202020202020202020202020202020202\",\"public_key_fingerprint\":\"sha256:75877bb41d393b5fb8455ce60ecd8dda001d06316496b14dfa7f895656eeca4a\"},{\"id\":\"participant-1\",\"display_name\":\"Participant\",\"key_id\":\"key-3\",\"ed25519_public_key_hex\":\"0303030303030303030303030303030303030303030303030303030303030303\",\"public_key_fingerprint\":\"sha256:648aa5c579fb30f38af744d97d6ec840c7a91277a499a0d780f3e7314eca090b\"}],\"roles\":[{\"id\":\"00000000-0000-4000-8000-000000000001\",\"role\":\"coordinator\",\"identity_id\":\"coordinator-1\"},{\"id\":\"00000000-0000-4000-8000-000000000002\",\"role\":\"release-signer\",\"identity_id\":\"signer-1\"},{\"id\":\"00000000-0000-4000-8000-000000000003\",\"role\":\"participant\",\"identity_id\":\"participant-1\"}],\"phases\":[{\"id\":\"phase1\",\"identity_ids\":[\"participant-1\"],\"minimum\":1},{\"id\":\"phase2\",\"identity_ids\":[\"participant-1\"],\"minimum\":1}]},\"result\":null}", + "valid": true, + "input_sha256": "sha256:cbc6e5f302f876b9e3c7a5cabc294c6639f117f418899b4558b2fb6142a37ce1", + "sha256": "sha256:3b49085076c7007ed2fc6e8c3feceaff0ebca8ca0d94a36a64a1662b8ecf8581" + }, + { + "name": "reject-incomplete-v3-object", + "json": "{\"schema\":\"ceremony-setup-v3\"}", + "valid": false + }, + { + "name": "reject-unknown-top-level-field", + "json": "{\"schema\":\"ceremony-setup-v3\",\"unexpected\":true}", + "valid": false + }, + { + "name": "reject-observer-as-authoritative-role", + "json": "{\"schema\":\"ceremony-setup-v3\",\"id\":\"10000000-0000-4000-8000-000000000001\",\"plan_revision\":1,\"plan\":{\"roles\":[{\"id\":\"00000000-0000-4000-8000-000000000001\",\"role\":\"witness\",\"identity_id\":\"witness-1\"}]},\"result\":null}", + "valid": false + } +] diff --git a/contracts/setupv3/ruleset.json b/contracts/setupv3/ruleset.json new file mode 100644 index 0000000..a03f69b --- /dev/null +++ b/contracts/setupv3/ruleset.json @@ -0,0 +1,28 @@ +{ + "id": "two-phase-v3", + "version": 3, + "definition_schema": "proof-tool-mpc-ceremony-definition-v3", + "circuits": [ + "rehearsal-tiny-v1", + "ownership-destination-v2" + ], + "roles": [ + "coordinator", + "participant", + "auditor", + "release-signer" + ], + "default_assurance_policy": { + "public_witnesses_per_phase": 1, + "mirrors_per_accepted_head": 1, + "passing_ceremony_audits": 1, + "external_security_audit_signoffs_rehearsal": 0, + "external_security_audit_signoffs_production": 0 + }, + "minimum_production_participants": 2, + "default_rehearsal_beacon_lead_seconds": 180, + "default_production_beacon_lead_seconds": 86400, + "minimum_beacon_lead_seconds": 1, + "maximum_phase_participants": 20, + "maximum_assurance_requirement": 20 +} diff --git a/contracts/setupv3/schema.json b/contracts/setupv3/schema.json new file mode 100644 index 0000000..ddb3606 --- /dev/null +++ b/contracts/setupv3/schema.json @@ -0,0 +1,471 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://zksecurity.github.io/relay/ceremony-setup-v3.schema.json", + "type": "object", + "additionalProperties": false, + "properties": { + "schema": { + "const": "ceremony-setup-v3" + }, + "id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "plan_revision": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "plan": { + "$ref": "#/$defs/Plan" + }, + "result": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/Result" + } + ] + } + }, + "required": [ + "schema", + "id", + "plan_revision", + "plan", + "result" + ], + "$defs": { + "Identity": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "display_name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "key_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "ed25519_public_key_hex": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "public_key_fingerprint": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + }, + "required": [ + "id", + "display_name", + "key_id", + "ed25519_public_key_hex", + "public_key_fingerprint" + ] + }, + "Role": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "role": { + "enum": [ + "coordinator", + "participant", + "auditor", + "release-signer" + ] + }, + "identity_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + } + }, + "required": [ + "id", + "role", + "identity_id" + ] + }, + "Phase": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "enum": [ + "phase1", + "phase2" + ] + }, + "identity_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "minItems": 1, + "maxItems": 20, + "uniqueItems": true + }, + "minimum": { + "type": "integer", + "minimum": 1, + "maximum": 20 + } + }, + "required": [ + "id", + "identity_ids", + "minimum" + ] + }, + "Ruleset": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "const": "two-phase-v3" + }, + "version": { + "const": 3 + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + }, + "required": [ + "id", + "version", + "sha256" + ] + }, + "Software": { + "type": "object", + "additionalProperties": false, + "properties": { + "release_tag": { + "type": "string", + "pattern": "^role-images-[0-9a-f]{40}$" + }, + "cli_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "proof_tool_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "manifest_sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "workflow_recipe_sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + }, + "required": [ + "release_tag", + "cli_commit", + "proof_tool_commit", + "manifest_sha256", + "workflow_recipe_sha256" + ] + }, + "Storage": { + "type": "object", + "additionalProperties": false, + "properties": { + "provider": { + "const": "aws" + }, + "region": { + "type": "string", + "pattern": "^[a-z]{2}(?:-[a-z]+)+-\\d$" + }, + "public_base_url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^https://[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z](?::[1-9][0-9]{0,4})?(?:/[^?#\\\\\\x00-\\x20\\x7f]*)?$" + }, + "published_bucket": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$" + }, + "inbox_bucket": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$" + } + }, + "required": [ + "provider", + "region", + "public_base_url", + "published_bucket", + "inbox_bucket" + ] + }, + "Beacon": { + "type": "object", + "additionalProperties": false, + "properties": { + "provider": { + "const": "drand" + }, + "network": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "chain_hash_hex": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "public_key_hex": { + "type": "string", + "pattern": "^[0-9a-f]{192}$" + }, + "scheme": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "genesis_time_unix": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "period_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400 + }, + "extraction": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "minimum_challenge_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "minimum_witness_lead_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "future_round_required": { + "const": true + } + }, + "required": [ + "provider", + "network", + "chain_hash_hex", + "public_key_hex", + "scheme", + "genesis_time_unix", + "period_seconds", + "extraction", + "minimum_challenge_bytes", + "minimum_witness_lead_seconds", + "future_round_required" + ] + }, + "AssurancePolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "public_witnesses_per_phase": { + "type": "integer", + "minimum": 0, + "maximum": 20 + }, + "mirrors_per_accepted_head": { + "type": "integer", + "minimum": 0, + "maximum": 20 + }, + "passing_ceremony_audits": { + "type": "integer", + "minimum": 0, + "maximum": 20 + }, + "external_security_audit_signoffs": { + "type": "integer", + "minimum": 0, + "maximum": 20 + } + }, + "required": [ + "public_witnesses_per_phase", + "mirrors_per_accepted_head", + "passing_ceremony_audits", + "external_security_audit_signoffs" + ] + }, + "Artifact": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "definition", + "definition-signature", + "coordinator-key", + "software-manifest", + "tool-receipt" + ] + }, + "platform": { + "enum": [ + "none", + "linux/amd64", + "linux/arm64" + ] + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "byte_length": { + "type": "integer", + "minimum": 1, + "maximum": 1048576 + }, + "content_b64": { + "type": "string", + "minLength": 4, + "maxLength": 1398104, + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + } + }, + "required": [ + "kind", + "platform", + "sha256", + "byte_length", + "content_b64" + ] + }, + "Plan": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "rehearsal", + "production" + ] + }, + "ruleset": { + "$ref": "#/$defs/Ruleset" + }, + "circuit": { + "enum": [ + "rehearsal-tiny-v1", + "ownership-destination-v2" + ] + }, + "beacon_policy": { + "$ref": "#/$defs/Beacon" + }, + "software_release": { + "$ref": "#/$defs/Software" + }, + "storage": { + "$ref": "#/$defs/Storage" + }, + "assurance_policy": { + "$ref": "#/$defs/AssurancePolicy" + }, + "identities": { + "type": "array", + "items": { + "$ref": "#/$defs/Identity" + }, + "minItems": 1, + "maxItems": 100 + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/$defs/Role" + }, + "minItems": 1, + "maxItems": 100 + }, + "phases": { + "type": "array", + "items": { + "$ref": "#/$defs/Phase" + }, + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "mode", + "ruleset", + "circuit", + "beacon_policy", + "software_release", + "storage", + "assurance_policy", + "identities", + "roles", + "phases" + ] + }, + "Result": { + "type": "object", + "additionalProperties": false, + "properties": { + "input_sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "protocol_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "artifacts": { + "type": "array", + "items": { + "$ref": "#/$defs/Artifact" + }, + "minItems": 4, + "maxItems": 6 + } + }, + "required": [ + "input_sha256", + "protocol_id", + "artifacts" + ] + } + } +} diff --git a/contracts/setupv3/setup.go b/contracts/setupv3/setup.go new file mode 100644 index 0000000..796370f --- /dev/null +++ b/contracts/setupv3/setup.go @@ -0,0 +1,403 @@ +// Package setupv3 defines the public website/CLI setup contract. Validation here +// checks structure and consistency, not protocol signatures or release provenance. +package setupv3 + +import ( + "bytes" + "crypto/sha256" + _ "embed" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/url" + "strconv" + "strings" + "sync" + "unicode/utf8" + + "github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +const MaxBytes = 8 * 1024 * 1024 + +//go:embed schema.json +var SchemaJSON []byte + +//go:embed ruleset.json +var RulesetJSON []byte + +//go:embed beacon.json +var BeaconJSON []byte + +const ( + DefaultRehearsalBeaconLeadSeconds uint32 = 180 + DefaultProductionBeaconLeadSeconds uint32 = 24 * 60 * 60 +) + +func Beacon(mode string, selectedLead ...uint32) map[string]any { + var b map[string]any + if err := json.Unmarshal(BeaconJSON, &b); err != nil { + panic(err) + } + lead := DefaultRehearsalBeaconLeadSeconds + if mode == "production" { + lead = DefaultProductionBeaconLeadSeconds + } + if len(selectedLead) == 1 { + lead = selectedLead[0] + } else if len(selectedLead) > 1 { + panic("Beacon accepts at most one selected lead") + } + b["minimum_witness_lead_seconds"] = float64(lead) + return b +} + +type Identity struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + KeyID string `json:"key_id"` + PublicKey string `json:"ed25519_public_key_hex"` + Fingerprint string `json:"public_key_fingerprint"` +} +type Role struct { + ID string `json:"id"` + Role string `json:"role"` + IdentityID string `json:"identity_id"` +} +type Phase struct { + ID string `json:"id"` + IdentityIDs []string `json:"identity_ids"` + Minimum int `json:"minimum"` +} +type Ruleset struct { + ID string `json:"id"` + Version int `json:"version"` + SHA256 string `json:"sha256"` +} +type Software struct { + ReleaseTag string `json:"release_tag"` + CLICommit string `json:"cli_commit"` + ProofToolCommit string `json:"proof_tool_commit"` + ManifestSHA256 string `json:"manifest_sha256"` + WorkflowRecipeSHA256 string `json:"workflow_recipe_sha256"` +} +type Storage struct { + Provider string `json:"provider"` + Region string `json:"region"` + PublicBaseURL string `json:"public_base_url"` + PublishedBucket string `json:"published_bucket"` + InboxBucket string `json:"inbox_bucket"` +} +type AssurancePolicy struct { + PublicWitnessesPerPhase int `json:"public_witnesses_per_phase"` + MirrorsPerAcceptedHead int `json:"mirrors_per_accepted_head"` + PassingCeremonyAudits int `json:"passing_ceremony_audits"` + ExternalSecurityAuditSignoffs int `json:"external_security_audit_signoffs"` +} +type Plan struct { + Mode string `json:"mode"` + Ruleset Ruleset `json:"ruleset"` + Circuit string `json:"circuit"` + BeaconPolicy map[string]any `json:"beacon_policy"` + SoftwareRelease Software `json:"software_release"` + Storage Storage `json:"storage"` + AssurancePolicy AssurancePolicy `json:"assurance_policy"` + Identities []Identity `json:"identities"` + Roles []Role `json:"roles"` + Phases []Phase `json:"phases"` +} +type Artifact struct { + Kind string `json:"kind"` + Platform string `json:"platform"` + SHA256 string `json:"sha256"` + ByteLength int `json:"byte_length"` + ContentB64 string `json:"content_b64"` +} +type Result struct { + InputSHA256 string `json:"input_sha256"` + ProtocolID string `json:"protocol_id"` + Artifacts []Artifact `json:"artifacts"` +} +type Setup struct { + Schema string `json:"schema"` + ID string `json:"id"` + PlanRevision int `json:"plan_revision"` + Plan Plan `json:"plan"` + Result *Result `json:"result"` +} + +func Hash(b []byte) string { return fmt.Sprintf("sha256:%x", sha256.Sum256(b)) } +func Canonical(v any) ([]byte, error) { + b, e := json.Marshal(v) + if e != nil { + return nil, e + } + return jsoncanonicalizer.Transform(b) +} +func Digest(domain string, v any) (string, error) { + b, e := Canonical(v) + if e != nil { + return "", e + } + return Hash(append([]byte(domain+"\n"), b...)), nil +} +func (s Setup) InputDigest() (string, error) { + return Digest("ceremony-setup-input-v3", map[string]any{"schema": s.Schema, "id": s.ID, "plan_revision": s.PlanRevision, "plan": s.Plan}) +} +func (s Setup) Digest() (string, error) { return Digest("ceremony-setup-v3", s) } +func (r Result) Digest() (string, error) { return Digest("ceremony-setup-result-v3", r) } +func Rules() Ruleset { + b, e := jsoncanonicalizer.Transform(RulesetJSON) + if e != nil { + panic(e) + } + return Ruleset{"two-phase-v3", 3, Hash(b)} +} + +var compiled = sync.OnceValues(func() (*jsonschema.Schema, error) { + var v any + if e := json.Unmarshal(SchemaJSON, &v); e != nil { + return nil, e + } + c := jsonschema.NewCompiler() + if e := c.AddResource("setup.json", v); e != nil { + return nil, e + } + return c.Compile("setup.json") +}) + +func depth(v any, n int) error { + if n > 16 { + return fmt.Errorf("JSON nesting exceeds 16") + } + switch x := v.(type) { + case map[string]any: + for _, v := range x { + if e := depth(v, n+1); e != nil { + return e + } + } + case []any: + for _, v := range x { + if e := depth(v, n+1); e != nil { + return e + } + } + } + return nil +} +func Parse(raw []byte) (*Setup, error) { + if len(raw) > MaxBytes { + return nil, fmt.Errorf("setup exceeds 8 MiB") + } + if !utf8.Valid(raw) { + return nil, fmt.Errorf("setup is not UTF-8") + } + // Bound nesting before the canonicalizer's recursive parser runs. + d := json.NewDecoder(bytes.NewReader(raw)) + nesting := 0 + for { + token, err := d.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if t, ok := token.(json.Delim); ok { + if t == '{' || t == '[' { + nesting++ + if nesting > 16 { + return nil, fmt.Errorf("JSON nesting exceeds 16") + } + } else { + nesting-- + } + } + } + // JCS rejects duplicate keys, invalid UTF-8, and lone Unicode surrogates. + canonical, e := jsoncanonicalizer.Transform(raw) + if e != nil { + return nil, fmt.Errorf("invalid setup JSON: %w", e) + } + var value any + if e = json.Unmarshal(canonical, &value); e != nil { + return nil, e + } + if e = depth(value, 0); e != nil { + return nil, e + } + schema, e := compiled() + if e != nil { + return nil, e + } + if e = schema.Validate(value); e != nil { + return nil, fmt.Errorf("setup schema: %w", e) + } + var s Setup + if e = json.Unmarshal(canonical, &s); e != nil { + return nil, e + } + if e = s.validate(); e != nil { + return nil, e + } + return &s, nil +} +func (s Setup) Validate() error { + b, e := json.Marshal(s) + if e != nil { + return e + } + _, e = Parse(b) + return e +} + +func (s Setup) validate() error { + p := s.Plan + lead, ok := p.BeaconPolicy["minimum_witness_lead_seconds"].(float64) + if !ok || lead < 1 || lead > float64(^uint32(0)) || lead != float64(uint32(lead)) { + return fmt.Errorf("beacon lead must be a positive whole number of seconds") + } + expectedBeacon, _ := Canonical(Beacon(p.Mode, uint32(lead))) + actualBeacon, _ := Canonical(p.BeaconPolicy) + if !bytes.Equal(expectedBeacon, actualBeacon) { + return fmt.Errorf("beacon policy must match the approved profile for this mode") + } + if p.Ruleset != Rules() { + return fmt.Errorf("unsupported ruleset digest") + } + if p.Mode == "production" && p.Circuit == "rehearsal-tiny-v1" { + return fmt.Errorf("tiny circuit is rehearsal only") + } + if p.SoftwareRelease.ReleaseTag != "role-images-"+p.SoftwareRelease.CLICommit { + return fmt.Errorf("release tag does not match CLI commit") + } + if p.Storage.PublishedBucket == p.Storage.InboxBucket { + return fmt.Errorf("published and inbox buckets must differ") + } + u, e := url.Parse(p.Storage.PublicBaseURL) + if e != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || strings.ContainsAny(p.Storage.PublicBaseURL, "?#\\\r\n\t ") { + return fmt.Errorf("public storage URL must be HTTPS without credentials, query or fragment") + } + if port := u.Port(); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n > 65535 { + return fmt.Errorf("invalid public URL port") + } + } + ids, keys, keyIDs := map[string]bool{}, map[string]bool{}, map[string]bool{} + for _, i := range p.Identities { + if ids[i.ID] || keys[i.PublicKey] || keyIDs[i.KeyID] { + return fmt.Errorf("each identity must have a distinct ID, key ID and public key") + } + ids[i.ID] = true + keys[i.PublicKey] = true + keyIDs[i.KeyID] = true + b, _ := hex.DecodeString(i.PublicKey) + if Hash(b) != i.Fingerprint { + return fmt.Errorf("identity %s fingerprint does not match its public key", i.ID) + } + } + used, roles, counts, participants := map[string]bool{}, map[string]bool{}, map[string]int{}, map[string]bool{} + allowedRoles := map[string]bool{"coordinator": true, "participant": true, "auditor": true, "release-signer": true} + for _, r := range p.Roles { + if !allowedRoles[r.Role] { + return fmt.Errorf("unsupported setup role %q", r.Role) + } + if roles[r.ID] || used[r.IdentityID] || !ids[r.IdentityID] { + return fmt.Errorf("each role must have a distinct ID and assigned identity") + } + roles[r.ID] = true + used[r.IdentityID] = true + counts[r.Role]++ + if r.Role == "participant" { + participants[r.IdentityID] = true + } + } + if len(used) != len(ids) { + return fmt.Errorf("unassigned public identity") + } + if counts["coordinator"] != 1 || counts["release-signer"] != 1 { + return fmt.Errorf("setup requires one coordinator and one release signer") + } + a := p.AssurancePolicy + if a.PublicWitnessesPerPhase < 0 || a.PublicWitnessesPerPhase > 20 || + a.MirrorsPerAcceptedHead < 0 || a.MirrorsPerAcceptedHead > 20 || + a.PassingCeremonyAudits < 0 || a.PassingCeremonyAudits > 20 || + a.ExternalSecurityAuditSignoffs < 0 || a.ExternalSecurityAuditSignoffs > 20 { + return fmt.Errorf("assurance requirements must be between 0 and 20") + } + if a.PassingCeremonyAudits > counts["auditor"] || (a.PassingCeremonyAudits == 0 && counts["auditor"] != 0) { + return fmt.Errorf("auditor assignments must match the signed ceremony-audit requirement") + } + // Witness and mirror identities are assigned and enrolled after initialization. + // The signed setup binds only their required counts, never mutable invitations. + if a.ExternalSecurityAuditSignoffs != 0 { + return fmt.Errorf("external security audit signoffs are not supported by this setup contract") + } + phases, scheduled := map[string]bool{}, map[string]bool{} + for _, phase := range p.Phases { + if phases[phase.ID] { + return fmt.Errorf("duplicate phase") + } + phases[phase.ID] = true + if phase.Minimum > len(phase.IdentityIDs) { + return fmt.Errorf("phase minimum exceeds its participants") + } + if p.Mode == "production" && (len(phase.IdentityIDs) < 2 || phase.Minimum != len(phase.IdentityIDs)) { + return fmt.Errorf("production requires at least two participants and all contributions per phase") + } + for _, id := range phase.IdentityIDs { + if !participants[id] { + return fmt.Errorf("phase member is not a participant") + } + scheduled[id] = true + } + } + if len(scheduled) != len(participants) { + return fmt.Errorf("each participant must be in at least one phase") + } + if s.Result != nil { + h, e := s.InputDigest() + if e != nil { + return e + } + if s.Result.InputSHA256 != h { + return fmt.Errorf("result belongs to a different setup plan") + } + found := map[string]bool{} + for _, a := range s.Result.Artifacts { + k := a.Kind + ":" + a.Platform + if found[k] { + return fmt.Errorf("duplicate artifact") + } + found[k] = true + if (a.Kind == "tool-receipt") == (a.Platform == "none") { + return fmt.Errorf("invalid artifact platform") + } + b, e := base64.StdEncoding.Strict().DecodeString(a.ContentB64) + if e != nil || base64.StdEncoding.EncodeToString(b) != a.ContentB64 || len(b) != a.ByteLength || Hash(b) != a.SHA256 { + return fmt.Errorf("artifact %s bytes do not match length or digest", a.Kind) + } + if a.Kind == "software-manifest" && a.SHA256 != p.SoftwareRelease.ManifestSHA256 { + return fmt.Errorf("software manifest differs from selected release") + } + } + for _, kind := range []string{"definition", "definition-signature", "coordinator-key", "software-manifest"} { + if !found[kind+":none"] { + return fmt.Errorf("missing %s artifact", kind) + } + } + } + return nil +} + +// SamePlan compares the canonical public input, including website ID/revision. +func SamePlan(a, b Setup) bool { + x, e := a.InputDigest() + y, f := b.InputDigest() + return e == nil && f == nil && bytes.Equal([]byte(x), []byte(y)) +} diff --git a/contracts/setupv3/setup.mjs b/contracts/setupv3/setup.mjs new file mode 100644 index 0000000..f1ce0ae --- /dev/null +++ b/contracts/setupv3/setup.mjs @@ -0,0 +1,120 @@ +// Public contract validation only. Callers must separately authenticate signed +// artifacts, release provenance, ownership, and the current website revision. +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import Ajv from "ajv/dist/2020.js"; +import canonicalize from "canonicalize"; + +export const MAX_BYTES = 8 * 1024 * 1024; +export const schema = JSON.parse(readFileSync(new URL("./schema.json", import.meta.url))); +export const ruleset = JSON.parse(readFileSync(new URL("./ruleset.json", import.meta.url))); +const beaconProfile = JSON.parse(readFileSync(new URL("./beacon.json", import.meta.url))); +export const beacon = (mode, selectedLead = mode === "production" ? 86400 : 180) => ({ ...beaconProfile, minimum_witness_lead_seconds: selectedLead }); +const validateSchema = new Ajv({ strict: false, allErrors: false }).compile(schema); +export const hash = bytes => `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +export const canonical = value => canonicalize(value); +export const digest = (domain, value) => hash(`${domain}\n${canonical(value)}`); +export const rules = () => ({ id: ruleset.id, version: ruleset.version, sha256: hash(canonical(ruleset)) }); +export const inputDigest = ({ schema, id, plan_revision, plan }) => digest("ceremony-setup-input-v3", { schema, id, plan_revision, plan }); +export const resultDigest = result => digest("ceremony-setup-result-v3", result); +export const setupDigest = setup => digest("ceremony-setup-v3", setup); +const require = (condition, message) => { if (!condition) throw new Error(message); }; + +export function parseStrict(bytes) { + const raw = Buffer.from(bytes); + require(raw.length <= MAX_BYTES, "Setup exceeds 8 MiB"); + const text = raw.toString("utf8"); + require(Buffer.from(text).equals(raw), "Setup is not UTF-8"); + let i = 0; + const ws = () => { while (/[\x20\t\r\n]/.test(text[i] || "")) i++; }; + const string = () => { + require(text[i] === '"', "Invalid JSON string"); + const start = i++; + while (i < text.length) { + if (text[i] === "\\") { i += 2; continue; } + if (text[i++] === '"') { + const value = JSON.parse(text.slice(start, i)); + require(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + ws(); require(depth <= 16, "JSON nesting exceeds 16"); + if (text[i] === '{') { + require(depth < 16, "JSON nesting exceeds 16"); + i++; ws(); const keys = new Set(); + if (text[i] === '}') { i++; return; } + while (true) { + ws(); const key = string(); require(!keys.has(key), "Duplicate JSON key"); keys.add(key); + ws(); require(text[i++] === ':', "Invalid JSON object"); value(depth + 1); ws(); + if (text[i] === '}') { i++; return; } require(text[i++] === ',', "Invalid JSON object"); + } + } + if (text[i] === '[') { + require(depth < 16, "JSON nesting exceeds 16"); + i++; ws(); if (text[i] === ']') { i++; return; } + while (true) { value(depth + 1); ws(); if (text[i] === ']') { i++; return; } require(text[i++] === ',', "Invalid JSON array"); } + } + if (text[i] === '"') { string(); return; } + const start = i; while (i < text.length && !/[\x20\t\r\n,}\]]/.test(text[i])) i++; + require(i > start, "Invalid JSON value"); + }; + value(0); ws(); require(i === text.length, "Trailing JSON data"); + return JSON.parse(text); +} + +export function parseSetup(bytes) { + const setup = parseStrict(bytes); + require(validateSchema(setup), `Setup schema: ${validateSchema.errors?.[0]?.instancePath || "/"} ${validateSchema.errors?.[0]?.message || "invalid"}`); + const p = setup.plan; + require(canonical(p.beacon_policy) === canonical(beacon(p.mode, p.beacon_policy.minimum_witness_lead_seconds)), "Beacon policy must match the approved profile except for its selected lead time"); + require(canonical(p.ruleset) === canonical(rules()), "Unsupported ruleset digest"); + require(p.mode !== "production" || p.circuit !== "rehearsal-tiny-v1", "Tiny circuit is rehearsal only"); + require(p.software_release.release_tag === `role-images-${p.software_release.cli_commit}`, "Release tag does not match CLI commit"); + require(p.storage.published_bucket !== p.storage.inbox_bucket, "Published and inbox buckets must differ"); + const u = new URL(p.storage.public_base_url); + require(!/%(?![0-9A-Fa-f]{2})/.test(p.storage.public_base_url), "Invalid public URL escape"); + require(p.storage.public_base_url.startsWith("https://") && u.protocol === "https:" && u.hostname && !u.username && !u.password && !/[?#\\\r\n\t ]/.test(p.storage.public_base_url), "Public storage URL must be HTTPS without credentials, query or fragment"); + const ids = new Set(), keys = new Set(), keyIDs = new Set(); + for (const identity of p.identities) { + require(!ids.has(identity.id) && !keys.has(identity.ed25519_public_key_hex) && !keyIDs.has(identity.key_id), "Each identity must have a distinct ID, key ID and public key"); + ids.add(identity.id); keys.add(identity.ed25519_public_key_hex); keyIDs.add(identity.key_id); + require(hash(Buffer.from(identity.ed25519_public_key_hex, "hex")) === identity.public_key_fingerprint, `Identity ${identity.id} fingerprint does not match its public key`); + } + const used = new Set(), roles = new Set(), counts = new Map(), participants = new Set(); + for (const r of p.roles) { + require(!roles.has(r.id) && !used.has(r.identity_id) && ids.has(r.identity_id), "Each role must have a distinct ID and assigned identity"); + roles.add(r.id); used.add(r.identity_id); counts.set(r.role, (counts.get(r.role) || 0) + 1); + if (r.role === "participant") participants.add(r.identity_id); + } + require(used.size === ids.size, "Unassigned public identity"); + require(counts.get("coordinator") === 1 && counts.get("release-signer") === 1, "Setup requires one coordinator and one release signer"); + const assurance = p.assurance_policy; + require(assurance.passing_ceremony_audits <= (counts.get("auditor") || 0) && (assurance.passing_ceremony_audits !== 0 || !counts.get("auditor")), "Auditor assignments must match the signed ceremony-audit requirement"); + // Witness and mirror identities are assigned and enrolled after initialization; + // only their required counts are part of the authenticated setup. + require(assurance.external_security_audit_signoffs === 0, "External security audit signoffs are not supported by this setup contract"); + const phases = new Set(), scheduled = new Set(); + for (const phase of p.phases) { + require(!phases.has(phase.id), "Duplicate phase"); phases.add(phase.id); + require(phase.minimum <= phase.identity_ids.length, "Phase minimum exceeds its participants"); + require(p.mode !== "production" || (phase.identity_ids.length >= 2 && phase.minimum === phase.identity_ids.length), "Production requires at least two participants and all contributions per phase"); + for (const id of phase.identity_ids) { require(participants.has(id), "Phase member is not a participant"); scheduled.add(id); } + } + require(scheduled.size === participants.size, "Each participant must be in at least one phase"); + if (setup.result) { + require(setup.result.input_sha256 === inputDigest(setup), "Result belongs to a different setup plan"); + const found = new Set(); + for (const a of setup.result.artifacts) { + const key = `${a.kind}:${a.platform}`; require(!found.has(key), "Duplicate artifact"); found.add(key); + require((a.kind === "tool-receipt") !== (a.platform === "none"), "Invalid artifact platform"); + const b = Buffer.from(a.content_b64, "base64"); + require(b.toString("base64") === a.content_b64 && b.length === a.byte_length && hash(b) === a.sha256, `Artifact ${a.kind} bytes do not match length or digest`); + require(a.kind !== "software-manifest" || a.sha256 === p.software_release.manifest_sha256, "Software manifest differs from selected release"); + } + for (const kind of ["definition", "definition-signature", "coordinator-key", "software-manifest"]) require(found.has(`${kind}:none`), `Missing ${kind} artifact`); + } + return setup; +} diff --git a/contracts/setupv3/setup_test.go b/contracts/setupv3/setup_test.go new file mode 100644 index 0000000..544fd8a --- /dev/null +++ b/contracts/setupv3/setup_test.go @@ -0,0 +1,127 @@ +package setupv3 + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +func TestSharedFixtures(t *testing.T) { + raw, err := os.ReadFile("fixtures.json") + if err != nil { + t.Fatal(err) + } + var fixtures []struct { + Name string + JSON string + Valid bool + Input string `json:"input_sha256"` + SHA string `json:"sha256"` + Result string `json:"result_sha256"` + } + if err = json.Unmarshal(raw, &fixtures); err != nil { + t.Fatal(err) + } + for _, f := range fixtures { + t.Run(f.Name, func(t *testing.T) { + s, err := Parse([]byte(f.JSON)) + if !f.Valid { + if err == nil { + t.Fatal("invalid setup accepted") + } + return + } + if err != nil { + t.Fatal(err) + } + input, err := s.InputDigest() + if err != nil || input != f.Input { + t.Fatalf("input digest %s: %v", input, err) + } + sha, err := s.Digest() + if err != nil || sha != f.SHA { + t.Fatalf("setup digest %s: %v", sha, err) + } + if s.Result != nil { + result, err := s.Result.Digest() + if err != nil || result != f.Result { + t.Fatalf("result digest %s: %v", result, err) + } + } + }) + } +} +func TestBoundsAndUTF8(t *testing.T) { + for _, b := range [][]byte{[]byte(strings.Repeat(" ", MaxBytes+1)), {'"', 0xff, '"'}} { + if _, err := Parse(b); err == nil { + t.Fatal("invalid bytes accepted") + } + } +} + +func fixtureSetup(t *testing.T) Setup { + t.Helper() + raw, err := os.ReadFile("fixtures.json") + if err != nil { + t.Fatal(err) + } + var fixtures []struct { + JSON string `json:"json"` + } + if err = json.Unmarshal(raw, &fixtures); err != nil || len(fixtures) == 0 { + t.Fatalf("load fixture: %v", err) + } + setup, err := Parse([]byte(fixtures[0].JSON)) + if err != nil { + t.Fatal(err) + } + return *setup +} + +func TestConfigurableBeaconLead(t *testing.T) { + setup := fixtureSetup(t) + setup.Plan.Mode = "production" + setup.Plan.Circuit = "ownership-destination-v2" + setup.Plan.BeaconPolicy = Beacon("production", 12) + setup.Plan.Phases[0].IdentityIDs = append(setup.Plan.Phases[0].IdentityIDs, "participant-2") + setup.Plan.Phases[1].IdentityIDs = append(setup.Plan.Phases[1].IdentityIDs, "participant-2") + setup.Plan.Phases[0].Minimum, setup.Plan.Phases[1].Minimum = 2, 2 + second := Identity{ID: "participant-2", DisplayName: "Participant 2", KeyID: "key-4", PublicKey: strings.Repeat("04", 32), Fingerprint: "sha256:9f4fb68f3e1dac82202f9aa581ce0bbf1f765df0e9ac3c8c57e20f685abab8ed"} + setup.Plan.Identities = append(setup.Plan.Identities, second) + setup.Plan.Roles = append(setup.Plan.Roles, Role{ID: "00000000-0000-4000-8000-000000000004", Role: "participant", IdentityID: second.ID}) + if err := setup.Validate(); err != nil { + t.Fatalf("short signed production lead rejected: %v", err) + } + setup.Plan.BeaconPolicy["minimum_witness_lead_seconds"] = float64(0) + if err := setup.Validate(); err == nil { + t.Fatal("zero beacon lead accepted") + } +} + +func TestOptionalAssuranceAndAssignments(t *testing.T) { + setup := fixtureSetup(t) + if err := setup.Validate(); err != nil { + t.Fatalf("zero assurance rejected: %v", err) + } + setup.Plan.AssurancePolicy.PublicWitnessesPerPhase = 1 + setup.Plan.AssurancePolicy.MirrorsPerAcceptedHead = 1 + if err := setup.Validate(); err != nil { + t.Fatalf("post-initialization observer requirements rejected: %v", err) + } + setup = fixtureSetup(t) + setup.Plan.AssurancePolicy.PassingCeremonyAudits = 1 + if err := setup.Validate(); err == nil { + t.Fatal("missing required auditor accepted") + } + setup = fixtureSetup(t) + setup.Plan.Roles[2].Role = "witness" + if err := setup.Validate(); err == nil { + t.Fatal("observer identity accepted in authoritative setup plan") + } + setup = fixtureSetup(t) + setup.Plan.AssurancePolicy.ExternalSecurityAuditSignoffs = 1 + if err := setup.Validate(); err == nil { + t.Fatal("unsupported external security audit requirement accepted") + } +} diff --git a/docs/coordinator-setup.md b/docs/coordinator-setup.md index 30e07dd..a8819e0 100644 --- a/docs/coordinator-setup.md +++ b/docs/coordinator-setup.md @@ -20,7 +20,8 @@ cannot run the helper. Install a matching new release rather than mixing binarie If the ceremony was drafted in Tessera, start with **Open setup downloaded from Tessera**. After initialization and verification, use **Export setup -for Tessera**. See the [website setup guide](tessera-setup-v2.md). +for Tessera**. See the [current website setup guide](tessera-setup-v3.md). +Relay still verifies existing [v2 setup files](tessera-setup-v2.md). Setup highlights **NEXT REQUIRED ACTION** with its reason. Use **Show other actions and requirements** for edits, optional checks, and offline preparation. diff --git a/docs/maintainer/README.md b/docs/maintainer/README.md index 2c25100..501ad3a 100644 --- a/docs/maintainer/README.md +++ b/docs/maintainer/README.md @@ -10,6 +10,9 @@ These references support release engineering, profile preparation, and recovery. | Try the coordinator setup UI locally | [Local coordinator test](local-coordinator.md) | | Review/test the guided role workflows | [Workflow review](role-workflow-review.md) | | Implement the agreed guided-journey redesign | [Journey design](guided-journey-design.md) | +| Design storage-first ceremony synchronization | [Storage-first workflow](storage-first-ceremony-design.md) | +| Review final verification and release-signer trust | [Release verification](release-verification-trust-model.md) | +| Implement the trusted coordinator and storage model | [Next implementation](trusted-services-implementation-plan.md) | | Design and audit crash-safe recovery | [Design](crash-recovery-design.md) · [Audit](crash-recovery-audit.md) | | Plan remaining recovery work and review its risks | [Remaining plan](crash-recovery-remaining-plan.md) | | Review/test role onboarding | [Onboarding review](onboarding-review.md) | diff --git a/docs/maintainer/release-verification-trust-model.md b/docs/maintainer/release-verification-trust-model.md new file mode 100644 index 0000000..8cf15fa --- /dev/null +++ b/docs/maintainer/release-verification-trust-model.md @@ -0,0 +1,239 @@ +# Final verification and release signing + +Status: reviewed proposal only. This document does not describe implemented or +released behavior. + +## Decision + +The protocol requires the coordinator to run proof-tool's complete mathematical +replay before final approval. The final-parameter signer does not repeat that +expensive replay by default. + +This preserves an existing coordinator requirement, not a new check: coordinator +finalization already called `replayAll` in proof-tool commit +[`c1f177e`](https://github.com/zksecurity/proof-tool/commit/c1f177ee486fd555fac0dc4d9812b86737fccdfd) +(July 31, 2026). PR #31 added the additional mandatory release-signer replay +on September 15. This proposal removes that duplicate requirement only for the +new format. The release-signer role itself remains required. + +This deliberately adopts an **honest coordinator** assumption for final +cryptographic verification. A coordinator signature authenticates the +coordinator's statement; it cannot independently prove the replay happened. + +We also trust the configured storage service to return committed state and +stored bytes according to its API. Independent freshness confirmations and +defenses against deliberate provider deception are outside this design. +Ordinary caching, timeout and interrupted-write handling remains required. + +The MPC secrecy assumption remains separate: at least one participant must +honestly discard its contribution randomness. + +Independent ceremony auditors remain optional. A positive audit minimum +requires their complete independent replays. A zero audit minimum means the +release has a coordinator-signed replay claim but makes no claim of independent +replay. + +## Reuse the final-candidate checkpoint + +Do not add a second coordinator verification record. The storage-first +`FinalCandidateRecorded` checkpoint already: + +- is produced only after proof-tool replays both phases and reproduces the final + parameters; +- binds the exact immutable checkpoint ancestry and closed replay inputs; +- binds the exact final candidate files by logical name, hash and size; and +- is signed by the coordinator only after proof-tool re-derives those facts. + +In the new version, that checkpoint is the coordinator's replay statement. Its +transition explicitly records `replay_verification.method: coordinator-full-replay-v1` +and the actual approved proof-tool executable digest. The inventory is derived +from authenticated ancestry; the coordinator cannot supply a second ad hoc +input list. + +It never relies on mutable `root.json`. A later frozen review checkpoint names +the exact final-candidate checkpoint record and signature. + +## Normal GO sequence + +```text +Coordinator authenticates the complete transcript + | + v +Proof-tool replays both phases and reproduces the final parameters + | + v +Coordinator signs FinalCandidateRecorded for those exact bytes + | + v +The review checkpoint freezes that checkpoint, evidence and final files + | + v +Final-parameter signer performs complete non-mathematical verification + | + v +Final-parameter signer signs those exact release files + | + v +GO may approve only that exact frozen release +``` + +Changing any bound byte requires another coordinator replay and a new +final-candidate checkpoint. + +## Release-signer verification boundary + +Proof-tool adds one named verification path for release signing. It traverses +the entire checkpoint ancestry and checks: + +- the signed definition, identities and approved software; +- every checkpoint signature and legal state transition; +- every referenced artifact's logical name, hash and size; +- participant, cleanup, custody, closure and beacon signatures; +- the signed assurance policy and every required evidence count; +- the exact `FinalCandidateRecorded` replay statement; and +- agreement between the frozen inventory and files being signed. + +It deliberately does **not** redo contribution mathematics or regenerate the +final parameters. For those expensive checks it relies on the coordinator's +signed final-candidate checkpoint under the honest-coordinator assumption. + +This is not the existing fully replaying stored-checkpoint verifier with a +different label. Tests must prove that the release-signing verifier rejects +every non-mathematical tamper while never invoking the contribution-replay +callbacks. + +## Independent replay + +The public, policy-enforced way to claim independent replay is the existing +ceremony-auditor control. If its signed minimum is positive, GO requires the +specified number of passing independent replay records. + +A release signer may voluntarily run the public replay command for personal +confidence. That does not create a second public assurance category. If the +ceremony wants to publish that assurance, the person must also be explicitly +assigned and enrolled as an auditor and submit the normal auditor record. + +If a voluntary replay finds a mathematical mismatch, Relay must pause and +recommend investigation and NO-GO. It must not immediately offer “sign without +replay.” An interruption or resource failure is shown as incomplete rather than +a mathematical failure and may retry the exact inputs. + +## NO-GO remains possible + +A passing final-candidate checkpoint gates GO, not NO-GO. + +If coordinator replay fails, is interrupted, or never produces a valid signed +final-candidate checkpoint, Relay can still create a terminal NO-GO decision. +That decision binds the exact immutable checkpoint record and signature +selected by the authenticated root, the attempted candidate digest when +available, and a non-sensitive failure or review reason. It does not claim a +passing replay and does not require a release manifest. + +Likewise, a later independent replay mismatch can produce NO-GO bound to the +exact final-candidate checkpoint it rejected. Failed candidates remain private +except for the minimal signed rejection metadata. + +## Failure and restart behavior + +Final-candidate preparation and signing retain the guarded proof-tool flow: + +1. authenticate the exact inputs and parent checkpoint; +2. complete the mathematical replay; +3. re-authenticate that the inputs and parent are unchanged; +4. load and match the coordinator key; +5. re-derive and sign the exact checkpoint; and +6. write the checkpoint and signature atomically. + +Relay uploads immutable bytes before conditionally advancing `root.json`. A +crash can leave unreferenced bytes, but they do not indicate success. On +restart, Relay authenticates the current root: + +- exact retained output with the same parent and candidate may be adopted; +- an already committed identical descendant is complete; and +- a changed parent, sibling checkpoint or candidate requires a new replay. + +The review cannot freeze and GO cannot proceed until the authenticated +checkpoint chain contains the signed final-candidate checkpoint. + +## Public wording + +Relay, Tessera and release metadata state exactly what can be established: + +- a valid coordinator-signed checkpoint states that full replay passed; +- accepted independent auditor replays: signed minimum and count; and +- external security reviews: signed minimum and count. + +With zero independent audits, show: + +> The coordinator signed the FinalCandidateRecorded checkpoint stating that +> full cryptographic replay passed. This ceremony assumes an honest +> coordinator. No independent full replay was required or claimed. + +Do not call this independently verified or trustless. Upload success and a zero +exit status alone never become a replay claim. Recorded times establish signed +ordering claims, not external wall-clock proof. + +## Versioning and compatibility + +The proof-tool release that currently requires release-signer replay retains +that behavior for its schema. Do not silently change the meaning of its +released identifiers. + +This proposal needs a new version of every signed boundary whose verification +meaning changes: + +- storage-first definition/workflow identifier; +- final-candidate checkpoint transition; +- frozen review checkpoint; +- final ceremony transcript and inspection result; and +- Tessera setup contract and compatibility fixtures. + +Keep the application-facing `proof-tool-key-manifest-v1` unchanged. Its existing +signed `setup_transcript_hash` binds the exact ceremony transcript. Add final +transcript V3 with the explicit release-verification policy and exact signed +coordinator final-candidate checkpoint references. Include that checkpoint pair +in the closed release inventory. No second release-authorization signature is +needed. Ordinary application key-bundle verification remains unchanged; the +coordinator replay claim requires complete ceremony verification. + +Production decision V2 may retain its wire structure because it binds the exact +definition and release inventory. Its verifier must explicitly recognize +Definition V4 and require final transcript V3; it must never fall through to +legacy behavior. A new decision schema is needed only if its signed fields or +their meaning change independently of the already-bound ceremony version. + +New-version GO verification fails closed without the exact signed +`FinalCandidateRecorded` replay statement. Old verifiers must reject the new +path, and new verifiers preserve the old path's mandatory release-signer replay. +Cross-version substitution must fail. + +## Implementation boundary + +Proof-tool owns mathematical replay, authenticated state validation and +cryptographic records. Relay owns Docker orchestration, S3/R2 locations, +uploads, conditional root updates and user guidance. + +Relay may transport proof-tool's authenticated logical outputs. It may not +manufacture a passing replay statement, interpret command exit alone as one, or +teach proof-tool Relay-specific backend object keys. + +## Required tests + +- coordinator replay success and mathematical failure; +- interruption before signing and after atomic output; +- changed candidate, replay input, binary or parent before signing; +- stale parent, sibling checkpoint and conditional-root conflict; +- GO without the final-candidate replay statement; +- NO-GO without a passing statement or release manifest; +- complete non-mathematical release verification without math callbacks; +- tampered signature, hash, identity, policy, evidence, chronology or inventory; +- voluntary replay mismatch without a silent downgrade; +- legacy/new schema crossing in both directions; and +- Tessera displaying coordinator attestation without overstating it. + +## Explicit trade-off + +This removes duplicate expensive computation from the normal final-signer +journey. In exchange, when the signed audit minimum is zero, a dishonest or +compromised coordinator can sign a false replay claim. Signatures and hashes do +not remove that assumption. diff --git a/docs/maintainer/storage-first-ceremony-design.md b/docs/maintainer/storage-first-ceremony-design.md new file mode 100644 index 0000000..a00ef6e --- /dev/null +++ b/docs/maintainer/storage-first-ceremony-design.md @@ -0,0 +1,1553 @@ +# Storage-first ceremony workflow + +Status: revised proposal, September 16, 2026. The current implementation still +contains earlier envelope, acknowledgement and mandatory signer-replay behavior. +Existing released ceremonies retain their rules. This document and the linked +rollout plan specify the next version. + +Final verification and release-signing trust are specified separately in +[`release-verification-trust-model.md`](release-verification-trust-model.md). +That proposal replaces this document's earlier requirement that every +storage-first release signer independently replay both phases. + +This document defines the normal way Relay roles discover ceremony progress and +exchange public artifacts. S3 or R2 is the shared transport. ZIP packages remain +an explicit fallback for a deliberately offline machine or an unavailable +backend. + +## Outcome + +After the initial trust setup, a connected role should normally need only: + +- its own local signing key and identity; +- the independently confirmed coordinator-key fingerprint, ceremony ID and + approved Relay release; +- the public storage address; and +- typed temporary private access when that role needs to upload a submission, + read protected review material, or publish an approved release. + +When the role opens Relay, the CLI synchronizes from storage, authenticates what +it finds, and answers: + +1. Which ceremony is this? +2. Which phase is active? +3. What authenticated transcript head is storage currently showing? +4. Whose participant turn is next? +5. What has this role completed, and what must it do next? + +Users should not move individual files into Relay's internal directories or be +expected to know names such as `chain-0001.json`. + +Witness, mirror, ceremony-auditor and external-security-audit requirements are +chosen before initialization and signed into the ceremony definition. Any of +them may be disabled, including for production. Relay must not create, display +or wait for a disabled role's work. + +The precise promise is: + +> Every connected role derives the authenticated public ceremony position from +> storage. It derives its next instruction from that position plus its local +> identity, independently confirmed trust, private access and locally verified +> facts. + +Storage cannot reveal that a host was disconnected, a mirror really retained a +copy, a witness first observed something at a particular time, or a private +grant was received. Relay tracks those facts locally or in explicit signed +records instead of guessing them. + +## Trust and security boundary + +We trust the coordinator to run the required verification, follow the signed +ceremony rules and record outcomes honestly. We trust the configured storage +service to return the committed current state and stored bytes through its +documented API. Deliberate coordinator deception, storage rollback, hidden +newer states and manufactured split views are outside this version's model. + +Trust does not mean every operation succeeds. Network timeouts, stale CDN +caches, interrupted uploads, accidental overwrites, wrong local files and +concurrent processes still occur. Participant input remains subject to +signature, assignment and mathematical verification. + +| Area | Intended behavior | +| --- | --- | +| Mathematical verification | Coordinator fully replays; policy-required auditors also replay | +| Participant submission | Existing signed receipt, contribution and cleanup records | +| Acceptance | Signed checkpoint commits the exact verified result | +| Current step | Read the trusted storage root and validate its checkpoint | +| Upload retries | Resume identical files; delivery attempts do not express new participant consent | +| Release signer | Verify exact files, signatures and required evidence; optional independent replay | +| Failure handling | Detect incomplete uploads, wrong files, interrupted operations and concurrent writes | + +We retain signatures and hashes to identify authors and catch mismatched +artifacts. A listed object or completed upload does not establish acceptance; +only the committed checkpoint does. We retain scoped credentials to prevent +one role's ordinary access from modifying another role's files. + +Private signing keys and contribution randomness remain local. Long-lived +credentials are not ceremony artifacts. Bootstrap still checks the intended +coordinator, ceremony and storage address so users do not join the wrong setup. + +The follow-up implementation plan is +[Trusted coordinator and storage rollout](trusted-services-implementation-plan.md). + +## Signed assurance policy + +The definition contains these four mandatory integer minima: + +```json +"assurance_policy": { + "public_witnesses_per_phase": 0, + "mirrors_per_accepted_head": 0, + "passing_ceremony_audits": 0, + "external_security_audit_signoffs": 0 +} +``` + +Zero disables that control. A positive value requires at least that many +distinct, valid records. Missing is never interpreted as zero. The definition +validator bounds every minimum and rejects an audit minimum larger than its +frozen auditor roster. When ceremony audits are disabled, that roster must be +empty. Witness and mirror identities may enroll after initialization: a signed +checkpoint must contain enough distinct witness assignments, enrollments and +readiness records before an enabled phase can close. Mirror assignments reserve +known future phase/index slots before the corresponding candidate is accepted; +their later receipts bind the resulting exact accepted head. When either +minimum is zero, its post-definition assignments are forbidden. Later evidence +must agree with the same signed policy. + +Witness and mirror minima have fixed protocol maxima even though their +identities are assigned later. Distinctness is required within each quorum. One +identity may serve both phases or retain multiple heads, but it must have a +separate assignment and signed record for each exact scope. An enabled mirror +must be assigned and enrolled for that phase/index before its receipt can be +accepted. Listed ceremony auditors are eligible; at least the signed minimum +must enroll and submit passing reports, and every accepted report requires its +author's enrollment. `external_security_audit_signoffs` is a production-only +control and must be exactly zero in a rehearsal definition. + +When a control is enabled, every supplied record is verified even when it +exceeds the minimum. Evidence from a disabled role is rejected rather than +silently ignored, keeping the final inventory closed and unambiguous. External +security-audit signoffs are a separate production control, not ceremony-role +enrollments. + +The signed definition is the sole policy authority. An operational bundle may +not choose, copy with changes, or lower these minima. It either omits a policy +copy and is verified against the definition, or carries the complete canonical +policy plus definition digest and must match both exactly. Every checkpoint +that displays the policy must match the authenticated definition exactly before +Relay derives state. A bootstrap-capsule copy is only a preflight display hint; +Relay blocks if it differs from the subsequently authenticated definition. + +New signed schemas represent disabled evidence collections as explicit empty +arrays. Omitted arrays are invalid and never mean disabled. When a control is +enabled, every supplied artifact is verified even above the minimum. When it is +disabled, any assignment, grant, enrollment, receipt, report or signoff for +that control is a policy contradiction and is rejected. + +Disabling these controls does not weaken contribution mathematics, signed +transcript verification, cleanup acknowledgements, future-beacon verification, +coordinator approval or final release-signing requirements. It deliberately +removes different independent assurances: + +| Disabled control | Assurance no longer provided | +| --- | --- | +| Public witnesses | Independent evidence that closure was publicly visible before the beacon became known | +| Mirrors | Independent retention if the primary backend deletes or withholds data | +| Ceremony audits | Independent replay of this exact ceremony and final parameters | +| External security audits | Independent review of the broader implementation and operating design | + +Relay shows this loss when the coordinator chooses the policy, in the final +definition review, and in the final GO/NO-GO review. Other roles see one compact +policy summary. It does not repeat warnings before every command. + +## Independent bootstrap + +Backend contents alone cannot establish their own trust anchor. Before first +use, each role independently confirms: + +- the coordinator public-key fingerprint; +- the ceremony ID; +- the approved Relay release; and +- the public storage origin. + +Relay packages the non-secret values into one **bootstrap capsule** containing: + +- ceremony ID and public storage origin; +- coordinator public key; +- approved Relay release and workflow schema; +- the signed assurance-policy minima; +- a trusted checkpoint lower-bound sequence and digest; and +- the intended role or invitation reference. + +The capsule itself may arrive through storage, email or Tessera, but the role +independently compares the SHA-256 digest of its exact canonical bytes through +the agreed channel. A QR or human-readable authenticated code may be used only +if it preserves at least 128 bits of this digest and displays which fields it +binds; a short user-chosen or checksum-style code is not a trust anchor. An +invitee that already independently confirmed the coordinator key before sending +its identity may instead authenticate its capsule through the signed +setup-complete bridge described below. After that check, the large public files +can travel through storage because Relay verifies them. + +Relay treats a successful read of the configured storage root as its source of +current committed ceremony state. It validates the ceremony ID, signature and +referenced files, then derives the next action. + +Relay rereads that root on startup and before a consequential operation. +Mutable root responses bypass caches or are read through the provider API; +ordinary temporary inconsistencies get bounded rereads. Persistent disagreement +with a saved operation pauses that operation with a concrete error. + +There is no second freshness authority, random challenge, mirror confirmation, +or first-use proof of freshness. Tessera can supply access and notifications, +but its cached progress cannot veto or override the storage root. Local saved +checkpoints support cache reuse and recovery from interruptions, rather than +serving as a defense against a dishonest provider. + +## Storage layout + +The current two-bucket separation remains: + +```text +published bucket (publicly readable) + blob/sha256/ immutable bytes + setup//complete.json signed, create-once bridge + state//root.json one mutable discovery hint + state//phase1/head.json mutable phase hint + state//phase2/head.json mutable phase hint + +private inbox bucket + submissions////// + files/ immutable payloads + manifest.json written last + +protected review/release staging + review//// final files before GO/NO-GO +``` + +The three logical planes may use two physical buckets if the private inbox and +review prefixes have separate, tested permissions. Final proving parameters do +not enter the official public release location before an exact GO decision. + +| Credential | Minimum access | +| --- | --- | +| Ordinary connected role | Public read; no private access until a typed grant is issued | +| Submission grant | Create plus exact-key HEAD/GET within one preallocated attempt; no list, replace, delete or other prefix | +| Offline-return grant | Create plus exact-key HEAD/GET for one preallocated release-result or decision-signature attempt | +| Coordinator | Authenticated root read/conditional write, published-object create, and exact expected private submissions | +| Enabled auditor/release reviewer | Read only the exact protected review objects named by its grant | +| Upload station | Read only the exact GO-approved review objects; create only in the exact official release prefix; no list, replace, delete or unrelated inbox access | + +Provider preflight tests both allowed and denied operations. Storage-provider +administrators remain able to observe or disrupt data; cryptographic checks do +not remove that operational power. + +The exact prefix may retain compatible existing names such as `candidates/`, +`operational/`, `audits/`, `releases/` and `decisions/`. The important rules are +semantic, not spelling: + +1. Payloads are immutable and content-addressed where public. +2. A submission is discoverable only after its manifest is written last. +3. Mutable state files are hints to immutable content, never proof. +4. Every referenced object is downloaded with a size limit and re-hashed. +5. Existing bytes are never silently replaced. + +### Submission transport is not a new ceremony record + +Participant receipts, contribution attestations, cleanup records and optional +return handoffs are already signed by the participant and already bind the +ceremony, phase, turn, participant and relevant predecessor head or payload. +They are the participant-authored ceremony records. + +An inbox `manifest.json` is an **unsigned transport-completion marker**. Relay +writes immutable payload files first and this marker last so the coordinator +knows an attempt is ready to inspect. It has a versioned, closed schema: + +```json +{ + "schema": "relay-submission-transport-v1", + "ceremony_id": "sha256:…", + "attempt_id": "…", + "kind": "receipt|candidate", + "files": [ + {"name": "…", "sha256": "sha256:…", "size": 123} + ] +} +``` + +`files` is sorted, has no duplicates, and contains exactly the fixed transport +inventory for that submission kind. Relay rejects unknown names, path-like +names, missing entries, duplicate entries, invalid hashes or sizes, and any +value that disagrees with the checkpoint-selected ceremony, attempt or kind. +This validates the transport claim before downloading payloads; it does not +make the manifest ceremony evidence. + +The manifest is never an accepted-artifact inventory or a source of ceremony +authority. The coordinator obtains the preallocated attempt prefix from the +authenticated checkpoint, downloads only the named bounded files, then +proof-tool verifies their existing signatures and contents directly. +Proof-tool derives the canonical ceremony-logical names for every accepted +artifact; Relay only maps its private transport names to local input files. +Relay records proof-tool's verified references in the next checkpoint, not the +manifest digest. + +The manifest cannot choose the expected inventory. Proof-tool derives it from +the receipt, contribution and cleanup protocols; a missing required record, +extra or conflicting record, duplicate canonical name or hash mismatch rejects +the attempt. Relay may use the marker only to decide that it is worth fetching +the fixed attempt prefix. It never publishes an inbox-only file merely because +the marker listed it. + +Upload attempts are delivery tracking only. The participant signs the ceremony +artifact once; it does not separately approve each delivery attempt. An exact, +valid signed artifact may be redelivered through a replacement upload attempt. +Its existing signatures must still match the ceremony, phase, turn, participant +and predecessor. + +Two outcomes must remain distinct: + +- Cancel or reject a delivery attempt: retire that upload prefix; the same + valid artifact may be delivered through another allocated prefix. +- Reject a candidate: the signed checkpoint records its exact candidate digest + and prevents that candidate from later being accepted through any attempt. + +Candidate identity must be defined by proof-tool over the complete, closed +candidate: ceremony, phase, index, participant, predecessor, contribution, +attestation/signature, cleanup record/signature and required return-handoff +evidence. Reuse an existing authenticated record ID only if it binds that whole +set; otherwise introduce a domain-separated canonical digest. It is not merely +the hash of contribution.bin. A changed candidate requires fresh verification +and an explicit new disposition, not automatic acceptance as a retry. + +The rejection set is ceremony-scoped and append-only. A transport error does not +automatically reject a candidate. The coordinator records candidate rejection +only after authenticating the candidate identity/digest; malformed bytes can +retire a delivery without falsely attributing a candidate to a participant. +This version has no silent reversal of candidate rejection. + +The accepted checkpoint means that the coordinator accepted these exact +participant-authored bytes. It does not claim that a participant signed an +upload-attempt ID or personally performed a particular upload. + +## Authenticated state graph + +Relay should not trust one global `stage` field. A ceremony can have concurrent +work: a mirror may be synchronizing while an auditor waits, and the next +participant may prepare while the coordinator verifies evidence. + +Instead, Relay builds an authenticated state graph from protocol artifacts: + +```mermaid +flowchart LR + D[Signed definition and assurance policy] --> E[Core enrollments verified] + E --> H0[Phase 1 head 0] + H0 --> O1[Signed outbound handoff] + O1 --> R1[Signed participant receipt] + R1 --> C1[Candidate submission] + C1 --> A1[Accepted Phase 1 head 1] + A1 --> ON[Next turn or closure] + ON --> W1[Policy-required witness readiness, or explicit zero] + W1 --> CL1[Signed Phase 1 closure] + CL1 --> B1[Verified future beacon] + B1 --> S1[Signed Phase 1 seal] + S1 --> H2[Phase 2 head 0] + H2 --> P2[Phase 2 turns, closure, beacon and seal] + P2 --> F[Final parameter files] + F --> AU[Policy-required evidence verified] + AU --> DEC[Signed production decision] + DEC --> REL[Published release or private rejection archive] +``` + +Each node is identified by exact bytes, hashes, signatures and ceremony +position. Edges are protocol prerequisites. The CLI derives a readable global +summary from this graph, then derives a role-specific next action. + +### Signed checkpoints and the discovery root + +There is one mutable discovery object: `root.json`. It contains only a reference +to an immutable, coordinator-signed checkpoint. The root locates the committed checkpoint; the +checkpoint contains the signed ceremony decision. + +Each checkpoint commits to: + +- schema and workflow version; +- ceremony ID and protocol/workflow version; +- all four signed assurance-policy minima; +- a monotonic sequence number; +- the previous checkpoint digest; +- both phase heads and current closure/beacon/seal state; +- the exact accepted public-artifact inventory; +- typed pending submission slots containing kind, assignment/scope, opaque + attempt ID, parent checkpoint/head and status; and +- any final decision and release state. + +Transport boundary: Relay maintains the corresponding delivery map: approved +Relay release, provider locations and exact prefixes for each opaque slot. +Proof-tool neither interprets object keys nor generates Relay manifests. +References below to a "checkpoint-allocated prefix" mean the Relay mapping +for that checkpoint's allocated slot, not a prefix in proof-tool's schema. +The new version must specify how this map is authenticated and bound to the +ceremony/checkpoint before implementation; do not remove existing authenticated +fields without replacing that binding. This is an explicit API-design item in +the [implementation plan](trusted-services-implementation-plan.md). + +Checkpoint validity includes a deterministic +`ValidateCheckpointTransition(previous, next)` rule. A valid coordinator +signature and previous digest are necessary but not sufficient. The verifier +also requires: + +- immutable ceremony, definition and workflow identities; +- accepted artifacts to remain append-only; +- a phase head to extend its authenticated predecessor chain; +- closure only from an allowed head, and the next phase only after a valid seal; +- observer/evidence collections to be recomputed against the signed minima + from the current indexed authenticated set, not remembered failed checks; +- the checkpoint policy projection to equal the authenticated definition at + checkpoint zero and every descendant; +- the first outbound turn to follow verified coordinator, release-signer and + scheduled-participant enrollments; +- each enabled phase closure to follow enough distinct phase-scoped witness + assignments, enrollments and readiness records; +- each enabled mirror receipt to follow an assignment and enrollment for that + identity and preallocated phase/index slot, then bind the resulting exact + accepted head; +- pending submission slots to move only through `allocated`, `accepted`, + `rejected` or `cancelled`, with replacement attempts explicitly bounded and + allocated. At most one active slot may exist for one kind, phase, index, + identity and parent head. Temporary credential expiry does not expire an + attempt; and +- a terminal GO or NO-GO to be absorbing, except for the narrow post-GO + publication-confirmation transition. + +This versioned transition verifier is shared by state derivation and proof-tool +verification before the cached ceremony position advances. + +A checkpoint can name: + +- the signed definition and its signature; +- current Phase 1 and Phase 2 head records; +- closure, beacon and seal records; +- accepted enrollment records; +- accepted custody and candidate records; +- witness, mirror and audit evidence accepted for review when enabled; +- the final parameter manifest; +- a production approval or rejection; and +- a published release manifest. + +Relay validates the checkpoint's canonical bytes, signature, schema and limits, +then follows only content-addressed references under the bootstrapped storage +origin. It re-hashes each object and asks proof-tool to authenticate the +referenced ceremony records. A signature on the checkpoint does not replace the +artifact's own protocol verification. + +Checkpoint publication uploads all immutable objects and the signed checkpoint +first, then moves `root.json` once with a provider-supported conditional write +against the exact ETag/version read earlier. Relay must implement and live-test +real S3 and R2 conditional replacement; a HEAD-then-unconditional-PUT imitation +is not sufficient. Immediately before that write, Relay verifies that the new +checkpoint's `previous_checkpoint` is exactly the checkpoint named by the root +whose durable ETag/version it is using. Recovery repeats the same comparison; +an ETag from a different root read can never commit the checkpoint. + +V1 permits one state-changing coordinator workspace. It holds an exclusive +local lock from review through signing and root publication. Cross-machine +coordinator mutation and automatic coordinator failover are unsupported. The +conditional root update detects an unexpected competing writer but cannot undo +two children that were already signed, which is why Relay must not sign from a +second coordinator workspace. + +Only the child selected by the committed root is accepted. A signed child +whose conditional update did not commit remains unused. Relay reconciles a +timeout by reading the root: identical committed bytes complete the operation; +an unchanged parent permits resuming the saved operation; another committed +child requires refreshing state and resolving the conflict. + +The existing per-phase head pointers remain useful for large transcript sync +and compatibility. They are cache hints only: authorization and guide state use +the phase heads committed by one checkpoint. A disagreement is ignored or +repaired; Relay never combines mutable pointers from different snapshots. + +Each machine retains the last verified checkpoint and any unfinished operation. +When startup or an action discovers inconsistent cached data, it rereads the +trusted root and checks the associated files. It does not merge contradictory +states or replay a mutation merely because its local checklist says incomplete. + +Safe reconstruction of read-only cache is permitted. Records of unfinished +signing, contribution or upload operations are preserved and reconciled before +continuing. A different root is an ordinary stale-operation or concurrency +condition, not a claim that the storage service is malicious. + +Checkpoint objects, signatures and ancestry are permanent ceremony artifacts in +V1 and are never garbage-collected. The policy bounds checkpoint count and byte +size. A later compaction design would need signed skip links or snapshot +certificates before any ancestor could be removed. + +Checkpoint signing runs in the coordinator's network-disabled signing container. +The connected Relay process supplies canonical checkpoint bytes and receives +only the public signature. This is an operational checkpoint signature: every +referenced ceremony artifact still needs its own proof-tool verification. + +### Synchronization algorithm + +Opening a connected role performs bounded metadata synchronization: + +1. Fetch the one root from the bootstrapped origin. +2. Fetch its named immutable checkpoint by digest. +3. Walk every checkpoint missing between the cached/bootstrap checkpoint + and the root checkpoint. For each step, verify the checkpoint envelope, + digest and signature, then fetch and authenticate every small referenced + artifact required to validate that transition. Missing ancestry, invalid + inner records, excessive depth or excessive total bytes blocks + synchronization; verified prefixes are cached. +4. Fetch and authenticate the small records needed to determine this role's + assignment. A checkpoint signature never substitutes for these protocol + checks. +5. Only after the complete checkpoint protocol state passes, atomically write + and sync its digest, sequence and phase-head digests. If this durable write + fails, Relay permits read-only inspection only. +6. Derive global position, ready/waiting actions and the next instruction. +7. Download large transcript or parameter payloads only when the selected task + needs them. Mirrors and auditors may request a full synchronization. +8. Immediately before signing, contribution, grant issuance, acceptance or + publication, reread the root. If it no longer names the expected checkpoint, + recompute prerequisites instead of continuing from stale input. + +Absolute URLs supplied by a checkpoint are rejected. References are safe +relative object keys beneath the independently bootstrapped origin. + +The coordinator obtains and conditionally replaces `root.json` through the +authenticated provider API, not a CDN or anonymous public URL. After committing, +it separately verifies that the public origin serves the exact root and +checkpoint. + +### Artifact authorship + +| Artifact | Signer | What it means | +| --- | --- | --- | +| Definition and initial transcript | Coordinator | Freezes the ceremony, roster, policy and initial state | +| Observer assignment | Coordinator | When enabled, assigns one witness or mirror identity, number and scope | +| Checkpoint | Coordinator | Accepts one coherent graph of already verified artifacts | +| Outbound custody record | Coordinator | Offers exact public input for one participant and parent head | +| Input receipt | Assigned participant | Confirms receipt of those exact input bytes | +| Candidate attestation, cleanup record and optional return handoff | Assigned participant | States the exact public output, cleanup claim and custody return when applicable | +| Accepted chain and checkpoint | Coordinator | Accepts that exact verified output or records a typed rejection | +| Witness or mirror receipt | That assigned observer | When enabled, reports its own observation or retained copy for one exact checkpoint | +| Audit result | Assigned auditor | When enabled, reports verification of the named complete input set | +| Release manifest | Release signer | Signs the exact final file inventory reviewed offline | +| GO or NO-GO decision | Identities required by signed policy | Authorizes only the exact manifest and evidence named | +| Discovery root | Nobody | Locates a checkpoint; it is never authority | + +The checkpoint cannot convert an invalid inner artifact into a valid one. Its +signature means the coordinator accepted this coherent set, while the inner +signature identifies who made each underlying claim. + +### State used by the guide + +For every role, Relay computes four values: + +| Value | Contents | +| --- | --- | +| Current state | Verified checkpoint and artifacts, local identity, authenticated assignments, cached position and unresolved local operations | +| Ready actions | Authored actions whose cryptographic and operational prerequisites are satisfied | +| Waiting actions | Authored actions with an exact missing input or another role that must act first | +| Next action | The highest-priority ready required action; if none is ready, the first blocking wait | + +File presence alone never satisfies a prerequisite. Verification results are +bound to each file's digest, so replacing a file invalidates the result. + +Actions form a dependency graph, not one total list. Authored order is a stable +tie-breaker between ready actions; it is never a heuristic based only on which +files happen to exist. A waiting action does not hide unrelated safe preparation, +but no action may skip an actual predecessor. + +The same authenticated facts and role identity must produce the same next +action under the same Relay release. + +## Before initialization + +A signed ceremony ID and checkpoint do not exist while the coordinator is still +assembling the roster. This is a separate draft lane, and the CLI must call it a +**coordinator draft**, never authenticated ceremony state. + +1. The coordinator creates a random provisional setup ID and role invitations. +2. Each invitee independently generates its key and uses a narrow invitation + grant to upload only `identity.json` to a protected setup inbox. +3. The coordinator reviews the expected identities and roles. The core roster + and any ceremony auditors are frozen at initialization; enabled witness and + mirror assignments may be added later under the signed minima. +4. Initialization freezes that roster, assurance policy, storage namespace, + workflow schema and initial checkpoint. +5. The coordinator publishes a signed, create-once record at + `setup//complete.json` in the publicly readable plane. + It contains a bounded map from each unguessable invitation ID to that role's + bootstrap-capsule digest. The random setup and invitation IDs provide + discovery privacy; the signature and previously confirmed coordinator key + provide authenticity. +6. Invitees poll that path, verify it with the coordinator key they already + confirmed, accept only the entry matching their own invitation and identity, + verify that capsule digest, and continue without a second manual handoff or + independent digest comparison. +7. Formal proof-of-possession enrollments follow against the signed ceremony. + Before the first participant turn, a checkpoint verifies the coordinator, + release-signer and all scheduled-participant enrollments. Witness readiness + is checked before each enabled phase closure. Mirror assignments reserve + phase/index slots before candidate acceptance, and their exact-head receipts + are required before final evidence review. + +An invitation grant is tied to one setup ID, role and assignment. Uploading an +identity does not assign or authenticate it; coordinator review and signed +initialization do. If a deployment cannot issue invitation grants, identity JSON +is the one connected-role public handoff allowed through the agreed channel. +An unused provisional invitation is only a draft artifact: it confers no +ceremony role, must expire or be revoked, and never enters a signed checkpoint +or final evidence inventory. The prohibition on disabled-role grants and +assignments applies after the assurance policy is frozen. + +## One complete Phase 1 turn + +This is the first implementation slice. + +### 1. Coordinator publishes the turn + +From the authenticated chain head and signed participant schedule, Relay derives +the next participant automatically. The coordinator prepares and signs the +outbound custody record for that exact head and participant. The online Relay +process uploads the record and every named public input, then updates the +signed checkpoint and discovery root. + +The coordinator CLI now says that it is waiting for the assigned participant's +signed receipt. It does not offer a grant first. + +### 2. Participant discovers and receives it + +The participant CLI synchronizes the signed checkpoint and head, confirms that its +identity is next, downloads the exact outbound record and named inputs, and +verifies all hashes and signatures. No manual directory placement is needed. + +The participant creates the existing signed input receipt in the +network-disabled signing container and uploads its record and detached signature +to the private inbox prefix. That receipt already binds ceremony, phase, turn, +parent head, participant identity, coordinator handoff and exact received files. +The outer manifest is an unsigned transport-completion marker, not +authentication. The participant retains the attempt ID locally. + +The outbound checkpoint preallocates that receipt attempt and exact inbox +prefix. Tessera or the protected control channel supplies a +receipt-submission grant restricted to it. This is distinct from the later +candidate grant. The authenticated checkpoint—not the participant's local +copy—remains the authority for the attempt ID; the local copy only assists +journaling and recovery. + +### 3. Coordinator verifies the receipt and issues a grant + +The coordinator fetches the unsigned manifest from the exact preallocated +inbox prefix, downloads the fixed receipt files with strict limits, and verifies +their existing participant signature, ceremony, phase, turn, parent head and +file hashes. The next signed checkpoint is the acceptance record. Only then may +Relay issue the participant's temporary candidate-upload grant through Tessera +or the protected coordination channel. + +Before signing the receipt-accepted checkpoint, the coordinator allocates the +candidate attempt ID and commits its exact inbox prefix in that checkpoint. It +then issues the grant for that already committed prefix. +The grant is never put in the published bucket. The storage credential is +restricted to one checkpoint-allocated prefix. Relay and Tessera verify its +association with the signed head, outbound record, receipt-accepted checkpoint, +participant and candidate attempt ID before issuing it; the credential itself +does not cryptographically contain those facts. Participant credentials +may `HEAD`/`GET`/create objects only within their exact attempt prefix so Relay +can safely resume without giving them bucket-wide `LIST`, overwrite or delete. + +### 4. Participant contributes and submits + +The participant rechecks the published head immediately before computation. +Relay runs the contribution in its disposable, network-disabled container, +checks container cleanup, creates the existing signed contribution attestation +and cleanup record, and uploads immutable files followed by `manifest.json`. + +Upload completion means only **submitted for verification**. + +### 5. Coordinator accepts the exact candidate + +The coordinator fetches the unsigned manifest from the preallocated candidate +prefix, then verifies the existing signed contribution attestation, cleanup +record and optional return handoff. Proof-tool checks identity, assignment, +parent head, contribution proof, cleanup claim and exact file hashes. If a +return handoff is required, the participant creates and signs it and includes +it in the same submission; the coordinator never authors a participant-side +custody event. + +The coordinator advances the signed chain only for that exact candidate. The +new signed checkpoint is the sole globally committed acceptance transition: it +binds the accepted participant records, identity, scope, attempt and exact +artifact hashes. The accepted chain remains the underlying cryptographic +contribution record. A rejection checkpoint binds the allocated attempt, kind, +identity, basis checkpoint, parent head and a non-sensitive reason code; it may +carry observed digests for diagnosis, but never treats invalid payloads or the +unsigned manifest as accepted ceremony evidence. It permits a new attempt. + +The new accepted chain and referenced public bytes are uploaded first; a new +signed checkpoint is created and `root.json` moves last. + +### 6. Participant confirms acceptance + +The participant CLI observes the new signed chain and verifies that the accepted +record contains its exact candidate digest and expected parent head. Only then +does it report **accepted**. A different valid candidate by the same identity +does not complete this task. + +```mermaid +sequenceDiagram + participant C as Coordinator CLI + participant P as Participant CLI + participant PUB as Published storage + participant IN as Private inbox + C->>PUB: Outbound record + inputs, then checkpoint/root + P->>PUB: Sync and authenticate current turn + P->>IN: Signed receipt, manifest last + C->>IN: Fetch and verify receipt + C-->>P: Temporary private upload grant + P->>IN: Candidate + cleanup record, manifest last + C->>IN: Fetch and verify exact candidate + C->>PUB: Accepted chain + blobs, then checkpoint/root + P->>PUB: Verify its exact candidate was accepted +``` + +## Role-specific inference + +The coordinator, at least one participant, and the distinct release signer +remain core roles. Witness, mirror and auditor rows apply only when their signed +minimum is positive. A zero minimum removes the corresponding assignments, +grants, evidence dependencies and guide actions; an empty array alone never +proves that a role is disabled. + +If someone directly opens a witness, mirror or auditor launcher that the +authenticated policy disables—or for which that identity has no assignment— +Relay shows read-only ceremony status and **This role is not enabled or assigned +for this ceremony**. It refuses to create assignments, grants, submissions or +completion records from that launcher. + +| Role | Reads and authenticates | Writes | Derived next action examples | +| --- | --- | --- | --- | +| Coordinator | Signed public checkpoint graph plus bounded complete private-inbox submissions | Signed operational records, accepted transcript state and checkpoints | Publish outbound turn; verify receipt; issue grant; verify candidate; close phase | +| Participant | Definition, assignment, current head, outbound record and later accepted head | Signed receipt, candidate and cleanup record to its private prefix | Wait for turn; acknowledge input; contribute; submit; confirm exact acceptance | +| Witness, if enabled | Definition, signed observer assignment, announced closure and future beacon data | Signed observation to its private prefix | Start watcher; preserve first observation time; observe required interval; submit receipt; wait for acceptance | +| Mirror, if enabled | Definition, signed observer assignment and each authenticated published checkpoint | Signed receipt for its independent destination | Synchronize missing immutable objects; verify exact checkpoint; submit receipt; wait for acceptance | +| Auditor, if enabled | Complete authenticated transcript, expected evidence inventory and protected final files | Signed audit result to its private prefix | Wait for the complete required set; run full verification; submit result; wait for acceptance | +| Release signer | Frozen review checkpoint, coordinator-signed final-candidate checkpoint, exact final files and all policy-required evidence | Signed release ZIP; a separate decision-signature ZIP only if assigned that duty | Fully verify non-mathematical records and exact files while trusting the coordinator's replay claim; sign final release; later sign the production decision if assigned | +| Upload station | Offline return ZIP, then terminal GO checkpoint and matching protected release | Preallocated private release-result submission, then approved closed-world public release | Return signer result for coordinator verification; publish only when GO names the exact release bytes | + +When enabled, witness and mirror numbers, identities, phases and scopes live in +signed observer assignment records that their CLIs download automatically. +Uploading an identity does not create an assignment. Starting a witness watcher +creates a short-lived signed readiness submission bound to its assignment, +phase, checkpoint and expiry. The coordinator verifies the signed readiness +minimum before closure. This authenticates a readiness claim; it cannot prove +the witness kept watching. + +Enabled witnesses, mirrors and auditors infer what work exists from +authenticated public checkpoints. The coordinator does not scan or trust a +role-written submission index. It allocates each attempt in the predecessor +checkpoint before issuing an exact-prefix grant and records the expected inbox +prefix there. Tessera's submission notification is only a hint to fetch that +already known location. + +Replacement attempts are explicitly allocated and bounded; two valid attempts +are never resolved by a timestamp or “latest” heuristic. The coordinator +verifies and selects one exact attempt; the next signed checkpoint records the +outcome. Other roles see their acceptance or rejection in a later checkpoint. +An upload alone never completes their duty. The same pattern applies to +enrollment, witness, mirror, audit, release and decision submissions. + +Typed private grants cover three journeys: + +- exact-prefix submission writes for participants and observers; +- exact-object protected-review reads for auditors and offline-package staging; +- exact-prefix release promotion after GO. + +Tessera supplies them automatically. Standalone mode shows one explicit private +grant import as the missing prerequisite; it never asks the user to move the +public payload files manually. + +Every typed grant names the exact checkpoint and attempt it authorizes, so an +already required grant also confirms the operation it belongs to. Standalone V1 +does not add a separate manual status exchange before every action. + +Mirror destination credentials remain local to an enabled mirror. The shared +storage root supplies ceremony progress for all connected roles. No independent +checkpoint service is required. + +### Future beacon without witnesses + +Witnesses and the beacon are independent controls. Even when +`public_witnesses_per_phase` is zero, closure still binds one exact future drand +round. Relay waits for that round, retrieves the required independent relay +responses, verifies the drand signature and applies the randomness. It skips +only witness readiness, observation deadlines and witness receipts. + +Without witnesses, nobody independent attests that the closure was publicly +visible before the beacon became known. The coordinator's signed time and +backend history remain evidence, but are not a trusted timestamp. A malicious +coordinator could wait for the round, backdate a closure and publish both +afterward. The future drand mechanism still supplies public randomness when the +honest procedure commits before the round, but Proof-tool cannot establish that +ordering from the coordinator's signature alone. Ceremonies requiring that +assurance must enable witnesses. A future schema could support a separately +trusted commitment-timestamp source only if that source and its rules are +frozen in the signed definition before the relevant closure. An existing no- +witness ceremony can never add this assurance retroactively after the beacon +round is known. + +The definition keeps a beacon-round lead rule separate from any witness- +observation window; setting the witness minimum to zero never sets the beacon +lead or `future_round_required` to zero. Verification still checks the exact +committed round, drand signature and required independent relay responses. The +CLI and final decision display that prior public commitment was not +independently established. + +### Configurable beacon lead + +The coordinator chooses the beacon lead before initialization for both +rehearsal and production. Relay writes it into the policy, Proof-tool signs it +into the ceremony definition, and every later close, checkpoint and release +verifies that exact value. It cannot be changed after initialization. + +Relay offers mode-specific defaults rather than hard-coded validity rules: + +- rehearsal default: 180 seconds; automated tests may use 12 seconds; +- production default: 24 hours. + +A shorter production value is allowed, but the CLI must show the chosen value, +the recommended 24-hour value and the lost review/observation time immediately +before the coordinator signs the definition. It must never silently substitute +a shorter value. If public witnesses are enabled, the existing production +witness-observation window is reserved in addition to the configured beacon +lead, and Relay displays the resulting total minimum close-to-round wait. + +This is a new signed-policy behavior. Existing versioned setup contracts and +existing ceremonies keep their original rules. The storage-first Relay setup +contract gets a new version; its guided setup exposes the setting for both +modes and passes it unchanged to Proof-tool. + +## Exact approval example + +Suppose the final signer reviews release A, whose manifest hash is +`sha256:aaaa...`, and signs **GO** for that hash. A bug or operator later creates +release B with hash `sha256:bbbb...`. + +The upload station must reject B even if: + +- B belongs to the same ceremony; +- B has a valid release-manifest signature; and +- most files happen to be identical. + +Before review, proof-tool validates a closed inventory: required logical names, +counts, sizes, hashes, circuit, curve, backend and key version, with no extra +files. The coordinator then creates a signed **review checkpoint** that freezes +this complete input set. After the freeze, only release, decision, or one +explicit `ReviewCancelled` transition is allowed. Cancellation names the frozen +checkpoint and a non-sensitive reason. Only after it is committed may corrected +evidence be accepted and a new review checkpoint and offline package be created. +Signatures and ZIP returns bound to the cancelled review remain historical +records but can never authorize a decision. + +The approval record signs the exact manifest bytes digest—not a parsed +and reserialized copy—and names: + +- the ceremony and review checkpoint; +- manifest digest and size; +- final parameter, audit and evidence-bundle digests; +- decision sequence, policy and required signers; and +- destination and release identity. + +The production decision retains a fixed, visible gate list. Its status +vocabulary is `PASS`, `FAIL`, `PENDING` and `NOT_REQUIRED`. Gate status is +deterministic: + +| Production gate | Signed policy field | Required status when zero | Allowed GO status when positive | +| --- | --- | --- | --- | +| Public witnessing | `public_witnesses_per_phase` | `NOT_REQUIRED` | `PASS` | +| Independent mirrors | `mirrors_per_accepted_head` | `NOT_REQUIRED` | `PASS` | +| Ceremony audits | `passing_ceremony_audits` | `NOT_REQUIRED` | `PASS` | +| External security audit | `external_security_audit_signoffs` | `NOT_REQUIRED` | `PASS` | + +Zero requires `NOT_REQUIRED` and an explicit empty evidence inventory; `PASS` +would falsely claim the disabled assurance occurred and is rejected. A positive +minimum forbids `NOT_REQUIRED`, and GO requires `PASS` backed by the exact +policy-required evidence. Every other GO gate must also be `PASS`; a +coordinator cannot weaken this mapping in a later decision. + +When `passing_ceremony_audits` is zero, a coordinator-signed final-candidate +checkpoint stating that full replay passed is still required, but no +independent full replay is claimed. The frozen review checkpoint binds that +checkpoint, candidate, operational bundle and the complete ceremony- +audit and external-audit inventories. Those arrays are explicitly empty when +their minima are zero. When an external minimum is positive, the checkpoint, +release approval and decision all bind the exact closed report/signoff +inventory. + +`released_at` must strictly follow the authenticated frozen review checkpoint, +whose creation in turn strictly follows candidate finalization and operational- +bundle assembly. This is the single chronology anchor whether or not audits +exist; verification never compares with a zero timestamp. When enabled, every +supplied audit artifact is signature-, identity- and content-verified even +above the minimum. When disabled, any audit artifact is rejected as a policy +contradiction. + +These signed timestamps enforce claimed record ordering and require operators' +clocks to be sane; they are not an external proof of wall-clock time. The exact +checkpoint ancestry and bound artifact digests remain the authorization +boundary. + +Decision signatures bind the review checkpoint. The coordinator verifies the +required signatures and creates one **terminal decision checkpoint** descending +from it. The upload station fetches that terminal checkpoint, re-verifies this +dependency set, re-hashes its input and publishes only if every value matches. +Approval of A can never authorize B. An older GO cannot override a later +terminal outcome, and one review checkpoint cannot accept both GO and NO-GO. + +In plain language: changing any final file requires a new review and a new +approval. + +If the decision is **NO-GO**, Relay may preserve a privacy-checked archive in +protected storage for investigation, but it never publishes the final proving +parameters as an approved release. + +The final sequence is explicit: + +1. The offline release signer reviews the complete candidate/evidence set and + returns a signed release manifest. +2. The coordinator verifies it and prepares a decision bound to that manifest. +3. The accountable decision signers sign GO or NO-GO. These are always the + coordinator and release signer, plus every ceremony auditor whose accepted + report is bound into the decision. If the release signer is also a decision + signer, this requires a second offline exchange. +4. The coordinator publishes the one accepted terminal decision checkpoint. +5. A keyless upload station can promote only the exact GO-bound release. It may + publish only the approved manifest bytes, files enumerated by that manifest, + required signatures, and a deterministic release pointer derived from the + decision. Extra sidecars or unreviewed files are rejected. +6. The coordinator independently polls, downloads and verifies the official + release, then signs the final `ReleasePublished` checkpoint. Until then all + CLIs say **GO approved; publication not yet verified**. No upload-station + receipt is treated as authority or added to the approved public inventory. + +## Offline signer and ZIP fallback + +The final signer is the deliberate exception to live backend inference. Its +machine may be disconnected. + +1. An online staging machine synchronizes and authenticates the frozen review + checkpoint. +2. Relay exports one deterministic `.relay.zip` containing the exact bounded + input set and a strict manifest. +3. The offline machine verifies the package, approved software and trust + anchors, then signs the exact result. +4. Relay exports one return ZIP containing only the signed public result. +5. The online upload station imports and re-verifies it, then uploads it through + a preallocated `release-result` private submission slot. The coordinator + fetches and verifies that exact manifest before preparing a decision. +6. If the release signer is also a decision signer, its second offline return + uses a separately preallocated `decision-signature` submission slot. + +The ZIP records the source review-checkpoint digest. If that freeze was +explicitly cancelled, the return is rejected and a new package must be reviewed. +ZIP does not become the default for connected roles. + +During a storage outage, ZIP may carry already authenticated public inputs for +inspection or preparation, using the same strict manifests. Participant turns, +closure, witness observation, acceptance and publication pause until storage +returns. Only the deliberately offline +final-signer workflow may complete signing while disconnected. There is no +loose-folder mode that asks users to reconstruct Relay paths. + +## Failure and recovery + +The public backend is the recoverable shared record of accepted public progress. +Most downloaded local files are a verified cache. The coordinator's mutation +journal, every role's unresolved-operation journal, private grants, witness +observation time and local secrets are safety-critical local state and cannot be +reconstructed from public storage. + +Coordinator journaling has two durable levels: + +1. Before each inner signature, Relay records the operation ID, parent + checkpoint/root version, signer and scope, digest of the canonical unsigned + artifact and expected signature path. +2. After signing, Relay verifies and records the resulting signature digest. + Once every inner reference is fixed, Relay constructs the checkpoint and + durably records its canonical digest and expected root version before asking + for the checkpoint signature. + +Every journal update uses write, file sync, atomic rename and parent-directory +sync. Startup reconciles the oldest unresolved signature intent before allowing +another signature; it never jumps directly to a replacement checkpoint. Missing +or mismatched coordinator safety state after mutation blocks state-changing +work. The coordinator workspace is not a replaceable cache. + +- Downloads go to a staging directory, are size-checked, hashed and + authenticated, then move atomically into the role workspace. +- Uploads allocate a stable attempt ID before starting. Immutable payloads are + uploaded first and the manifest last. +- A missing manifest means the submission is incomplete and ignored. +- On restart, no manifest resumes only identical missing payload uploads before + creating the manifest. A valid manifest with matching payloads is adopted. + A manifest with temporarily unavailable payloads triggers bounded rereads; + conflicting manifest or payload bytes are never overwritten and require a + signed rejection or cancellation before a replacement attempt is allocated. +- A retry with the same checkpoint-allocated attempt ID verifies any existing remote bytes and + sends only missing identical objects. Acceptance deduplicates by signed record + ID, never object path or timestamp. +- A conflicting object is an error; Relay never overwrites it. +- Before a state update, Relay records the expected previous root version, + attempt/checkpoint ID and exact output digests. After a crash it reads those + exact objects and the current root. It then adopts the already committed exact + checkpoint, finishes the same conditional update if the root is unchanged, or + blocks because another checkpoint won. It never signs a replacement + acceptance automatically. +- Relay offers one concrete recovery action: continue the safe remaining work, + inspect an uncertain local result, or wait for another role. +- A read-only verification is re-runnable and its older failure does not block a + later successful verification of the current bytes. +- Signing, contribution and publication operations retain stricter uncertainty + handling when the child may have produced output. + +### Private grant recovery + +Before requesting any temporary credential, Relay durably records: + +```text +grant request ID +submission attempt ID and exact prefix +checkpoint digest +requested lifetime +``` + +Credential generations use separate states: + +```text +planned | issued | saved | export shown | delivery reported | expired/revoked +``` + +Tessera treats the grant request ID idempotently and returns the same unexpired +result. In standalone mode, Relay saves the credential atomically before showing +or exporting it. A saved unexpired credential can be shown again after restart; +Relay never equates that with recipient receipt. If issuance may have succeeded +but cannot be queried, Relay does not create another live credential until the +first is revoked or its maximum lifetime has passed. Delivery to the role +remains a separate private fact or explicit human report. + +An expired credential does not force recomputation. Relay renews access only to +the same already committed attempt prefix and resumes the upload. A replacement +contribution requires an explicit new attempt slot and grant. + +### What “restart” means + +Restart means reopening the same role workspace with its durable journal and +secrets intact. A new empty workspace is a first-use/adoption flow, not a +restart. + +| Situation | What Relay can safely do | +| --- | --- | +| Same workspace reopened | Synchronize public progress, reconcile the journal and continue the one safe remaining action | +| New workspace with restored key and bootstrap | Recover accepted public progress only; do not claim unpublished/local work | +| Participant lost local attempt state | Inspect the exact checkpoint-preallocated remote manifest with a replacement grant, or explicitly abandon it and allocate a new attempt | +| Witness lost observation bytes/time | Mark the observation missed or uncertain; never reconstruct a timestamp | +| Mirror lost destination mapping | Reconfigure the destination and prove retention again | +| Offline package lost | Export a new package for the current valid frozen review | +| Coordinator workspace lost after mutation began | Read-only inspection; do not sign from a replacement workspace | + +If the local journal is missing or corrupt while an operation may have produced +a signature, contribution or upload, Relay reports that recovery is +indeterminate and never repeats the operation automatically. + +Relay must not infer a human-only fact—such as independent key confirmation, +machine cleanup, or delivery of a private grant—unless there is an explicit +record for that fact. + +Every submission kind has an executable allowlist for paths, media types, file +counts and byte sizes. Relay snapshots regular files without following links and +rejects devices, links, traversal, unexpected files and private-material names. +Before promoting inbox contents, it runs the type-specific privacy and protocol +checks. Provider administrators can still see private inbox contents and +metadata; V1 does not claim otherwise. + +## Concurrency and ordinary storage failures + +- One coordinator workspace performs mutations; a local lock prevents two + processes from writing simultaneously. +- Conditional root updates prevent an older operation from replacing a newer + committed state. +- Each acceptance still matches the exact participant, turn and predecessor. +- A cached-root disagreement causes a bounded fresh read and reconciliation. +- Tessera's display may lag storage and is refreshed from it. +- Mutable root caching is disabled; immutable artifact caching is permitted. +- Timeouts do not imply failure or success: read the exact destination before + deciding whether an operation committed. + +Witnesses, when enabled, still use their actual first observation time and the +signed beacon target. Missed observation windows cannot be recreated by a +retry. Ordinary download failures may retry within the real observation window. + +Public downloads retain deadlines, file-size bounds, safe paths and staged +writes. No challenge-response freshness system, alternative trusted root or +malicious-provider simulation is required by this model. + +## Symbolic model + +Model the workflow as authenticated facts and transitions, not one enormous UI +tree. + +Example facts: + +```text +DefinitionVerified(ceremony) +AssuranceMinimum(ceremony, control, count) +Assigned(identity, role, index) +CheckpointVerified(sequence, digest, previousDigest) +HeadVerified(phase, n, digest, checkpointDigest) +OutboundPublished(phase, n+1, identity, parentDigest, attempt) +ReceiptVerified(phase, n+1, identity, outboundDigest, attempt) +GrantPlanned(requestID, attempt, checkpoint, exactPrefix, lifetime) +GrantIssued(requestID, attempt, checkpoint, exactPrefix, credentialGeneration) +CandidateSubmitted(phase, n+1, identity, candidateDigest, parentDigest, attempt) +CandidateAccepted(phase, n+1, identity, candidateDigest, parentDigest, attempt) +DeliveryRetired(attempt, reason) +CandidateRejected(ceremony, candidateDigest, reason) +PhaseClosed(phase, headDigest) +ReviewFrozen(reviewCheckpoint, manifestDigest, evidenceDigest) +ReviewCancelled(reviewCheckpoint, reasonCode) +ReleaseReviewed(reviewCheckpoint, manifestDigest) +DecisionSigned(outcome, reviewCheckpoint, manifestDigest) +TerminalDecisionCommitted(outcome, terminalCheckpoint, reviewCheckpoint, manifestDigest) +ReleaseUploaded(terminalCheckpoint, manifestDigest) +ReleasePublished(publishedCheckpoint, terminalCheckpoint, manifestDigest) +``` + +Each action declares preconditions and effects. For example: + +```text +AcceptCandidate requires + CheckpointVerified(s, cp, previous) + HeadVerified(p, n, h, cp) + Assigned(i, participant, n+1) + ReceiptVerified(p, n+1, i, outbound, receiptAttempt) + CandidateSubmitted(p, n+1, i, c, h, candidateAttempt) + CandidateNotRejected(ceremony, c) + ParticipantRecordsVerified(ceremony, p, n+1, i, c, h) + +AcceptCandidate produces + CandidateAccepted(p, n+1, i, c, h, candidateAttempt) + AcceptancePrepared(s+1, cp2, cp, h2) +``` + +The crash model expands that last abstract effect into observable steps: + +```text +InnerSignatureIntentDurable +AcceptanceSigned +ImmutableObjectsUploaded +CheckpointIntentDurable +CheckpointSigned +CheckpointUploaded +RootCASAttempted +RootCASCommitted +RootRead +CheckpointVerified +CachedPositionRecorded +ActionStarted +``` + +Only a validated checkpoint selected by the configured storage root produces +the normal `CheckpointVerified` fact. +Reconciliation may finish the same recorded conditional write or adopt its exact +committed result; it never creates a new signature automatically. + +The model must check at least these invariants: + +1. No action succeeds because an object merely exists. +2. No participant receives authorization before its exact input receipt passes. +3. No candidate is accepted for the wrong identity, turn or parent head. +4. A participant is complete only when the accepted chain names its exact + candidate. +5. No mutable hint is used as cryptographic authority. +6. No private grant or signing key reaches public storage. +7. No approval authorizes a different final manifest. +8. A rejection never produces an approved public release. +9. A cached-position conflict is reconciled against a fresh provider root read + before any dependent mutation. +10. For identical authenticated facts, identity and software version, the + guide chooses the same next required action. +11. Concurrent ready work is visible without allowing required prerequisites to + be skipped. +12. An offline return is accepted only for the checkpoint and files it reviewed. +13. Every signature requires its exact durable intent, and one parent produces + only one signed child unless recovery resumes the identical intent. +14. A root commit requires the exact signed checkpoint to exist immutably first, + its previous digest to equal the checkpoint in the root being replaced, and + the conditional token to come from that exact durable root read. +15. Cached position advances only for a validated, committed checkpoint. +16. Every mutation durably records its expected parent and exact inputs before + starting. +17. Grant issuance requires the matching durable request, attempt, checkpoint + and exact prefix. +18. Grant renewal preserves the same attempt and prefix; credential expiry does + not terminate the attempt. +19. A replacement attempt requires the previous slot to be explicitly rejected + or cancelled. +20. A role is skipped only when the authenticated definition sets its exact + minimum to zero; absence of a file, assignment or local fact cannot disable + it. +21. A disabled role has no assignment, grant, accepted evidence or waiting + action, while every supplied artifact for an enabled role is verified even + after its minimum is satisfied. +22. Zero witnesses removes only witness evidence; it never removes the future + beacon or verification of one signed drand response for the committed round. + A second endpoint is an availability fallback, not additional evidence. +23. GO accepts `NOT_REQUIRED` only for a gate whose matching authenticated + minimum is zero. +24. Delivery retirement does not reject an artifact; candidate rejection blocks + that exact candidate across all subsequent delivery attempts. + +Facts for enabled enrollment, witness, mirror and audit duties are indexed by +identity, assignment, checkpoint, attempt and artifact digest. Collection +completion is a predicate over the authenticated minima and current set, never +a sticky result from an old failed check. The model explores zero and positive +minima explicitly; it never treats a missing fact as a policy choice. + +TLA+ is suitable for the state, concurrency and crash properties. It does not +reimplement cryptography: signature and hash verification are modeled as +trusted predicates, then Go integration tests confirm the real verifier is +called at every boundary. TLC exhaustively checks bounded configurations; the +result is not a proof for unbounded ceremonies or the Go implementation. + +## Test strategy + +### Model tests + +- Use small compositional models for checkpoint/root/crash behavior, one phase + turn with duplicate attempts, evidence/quorum/timing, and decision/release. +- Explore reordered, duplicated, missing and conflicting checkpoint updates. +- Pair a valid checkpoint with another root version's ETag and confirm the + conditional update is rejected before publication and during recovery. +- Explore stale local heads, cache lag, delayed responses and concurrent + coordinator updates under documented provider consistency. Deliberate + provider rollback and split views are outside this model. +- Explore crash points before each payload, manifest, accepted chain, checkpoint + and root update. +- Mutate identity, phase, turn, parent digest, candidate digest and release + digest independently. +- Check safety invariants and useful reachability for every role in bounded + configurations. +- Add deliberately broken configurations to confirm every invariant can fail + when its guard is removed. +- Explore all eight enabled/disabled combinations for witnesses, mirrors and + ceremony auditors. Model external security-audit signoffs separately because + they are not ceremony-role assignments. +- Prove that zero is reachable only through an authenticated explicit minimum, + and that omitted policy, injected disabled-role evidence, and + `NOT_REQUIRED` on an enabled gate are rejected. +- Explore the honest no-witness path while recording the explicit limitation: + the model must not manufacture a trusted before-beacon commitment fact from a + coordinator timestamp. + +### Go tests + +- Define one versioned, machine-readable transition/action vocabulary with + stable predicate IDs. Export bounded model traces as checked-in fixtures that + map authenticated facts—including time, grant status and unresolved local + operations—to legal transitions and the next action. +- Run the real Relay evaluator and proof-tool transition verifier against every + fixture. CI regenerates and diffs the fixtures, or verifies their source hash; + mutation tests remove each dependency edge to prove the suite detects drift. +- Feed the real state-derivation engine synthetic S3/R2 object graphs. +- Assert the exact role summary, waiting reason and next action for every state. +- Require proof-tool verification results, not fixture file presence. +- Cover scoped role credentials, coordinator exact-prefix inbox retrieval, + existing participant record verification, bounded downloads, manifest-last + uploads and conditional root writes. +- Reject a manifest written before payload completion, missing/unknown/duplicate + transport names, bad hash/size, wrong ceremony/attempt/kind, confusing path + names and a manifest corrupted after otherwise valid signed payloads. Assert + that no unsigned manifest enters accepted evidence. +- Allow identical valid records to be redelivered in a newly allocated attempt + after delivery retirement. Reject wrong phase, index, participant, ceremony + or parent-head scope, mismatched transport manifests, and candidates whose + digest was rejected. Renewal retains one attempt/prefix; a retired attempt + is never revived or overwritten. +- Exercise crashes before and after every payload, manifest creation, + coordinator verification and root advancement, on both S3 and R2. +- Run existing-session compatibility tests against the old pointer layout. +- Test definition, two-phase operational evidence, final transcript, release + and decision verification for every witness/mirror/ceremony-auditor + combination. Include zero and positive external-audit minima in production + fixtures. +- Assert each released schema retains its actual shipped requirements from + the compatibility inventory, including optional-role and replay behavior; + require every new assurance field where the new schema specifies it. +- Assert that a no-witness ceremony still verifies the exact future beacon and + independent relay responses, and that a no-audit release still requires and + binds the coordinator's full replay of the final candidate and operational + bundle without claiming independent replay. +- Reject valid-looking witness, mirror, ceremony-audit or external-audit + evidence injected when its minimum is zero; reject disabled-role assignments, + grants and enrollments. +- Reject an operational bundle that authors or lowers a quorum, a checkpoint or + capsule whose policy differs from the definition, zero-minimum gates marked + `PASS`, and positive-minimum gates marked `NOT_REQUIRED`. +- Reject omitted evidence arrays in every new signed schema; accept only + explicit empty arrays when the corresponding signed minimum is zero. +- Reject a rehearsal definition with a nonzero external-security-audit minimum. +- Live-test conditional replacement and create-only object semantics on both S3 + and R2; unsupported or ambiguous behavior blocks production. + +### Guided role journeys + +For coordinator, participant, release signer and upload station, plus every +witness, mirror and auditor enabled by the signed policy: + +1. Start with a clean workspace and only that role's legitimate inputs. +2. Let the CLI obtain all ordinary public handoffs from storage. +3. Confirm the recommended instruction follows from authenticated state and + states exactly what is missing when waiting. +4. Interrupt each consequential operation once and resume. +5. Replace or remove one required backend object and confirm a clear failure. +6. Export a secret-free bug report. + +No test may manually place an internal file to make the happy path continue. +Such a fixture can test a verifier, but it cannot validate the user journey. + +### End-to-end gates + +- One complete tiny ceremony with all connected roles using storage-first + transport. +- One complete tiny ceremony with all four optional assurance controls set to + zero; it must contain no hidden observer/audit fixtures or manual completion + markers. +- The same tiny ceremony on both AWS S3 and Cloudflare R2. +- Two coordinator processes sharing one workspace, proving the exclusive lock + prevents the second from signing. A simulated unexpected root conflict must + block rather than publish another child. +- One offline final-signer ZIP round trip. +- One production-shaped run with a signed shortened beacon lead, plus separate + tests of the recommended 24-hour production setting and final + approval/rejection behavior. +- One clean released-version run; development images do not establish release + compatibility. + +## Compatibility and rollout + +Do not silently reinterpret an unfinished ZIP-first or explicit-file ceremony +as storage-first. + +- Existing ceremonies stay pinned to the Relay release and workflow that + created them. +- The new release creates a versioned storage-first workflow record. +- Existing definition, operational-bundle, final-transcript and production- + decision schemas retain their actual shipped policy and replay requirements, + documented in the required compatibility inventory. They are never + reinterpreted using the new rules. +- Optional roles use explicitly versioned signed schemas and rulesets. All four + assurance minima are mandatory in the definition and are projected by + proof-tool into Relay's authenticated journey state. +- Determine which identifiers have already shipped before selecting the new + schema/ruleset versions. The earlier proposed v3 identifiers may already be + released; do not reuse them for changed verification semantics. +- Update each signed boundary whose shape or meaning changes, and preserve + existing readers and frozen ceremony behavior. + +- Tessera's existing setup contracts remain unchanged and cannot create an + optional-role ceremony. A new versioned setup contract and compatibility + release are required before Tessera may enable this workflow. +- Relay rejects importing an old Tessera setup or ruleset into the new + definition schema. It never infers zero minima, adds a local assurance policy + to an old export, or converts missing fields into defaults. +- Existing `state///head.json` pointers and content-addressed + transcript blobs remain readable. +- A future adoption tool may import only authenticated, completed protocol + state. It must show what cannot be inferred and require an explicit new + checkpoint; it must not manufacture missing custody or acceptance records. +- The experimental branch is retained for reusable manifest, archive, exact + approval, privacy and adversarial-test work. + +## Implementation sequence + +The clean integration branches already exist. Do not restart them or treat +their existing implementation as completion of this revised design. +Follow the [current implementation plan](trusted-services-implementation-plan.md): + +1. Settle and version the simplified proof-tool API and signed schemas. +2. Complete one normal-menu, storage-backed Phase 1 turn in Relay. +3. Separate ordinary synchronization from mathematical replay. +4. Extend through both phases, optional evidence and exact final approval. +5. Test real role journeys, failures, compatibility and both storage providers. +6. Release proof-tool, pin and retest Relay, then update Tessera and release + the compatible pairing. Update role documentation alongside the actual CLI. + +### Component ownership + +- **proof-tool** owns canonical signed checkpoint, transition and decision + schemas plus verification of existing participant records. It remains + network-free. +- **Relay** owns storage synchronization, bounded transport, conditional writes, + local cached state, Docker execution, recovery and deterministic + role guidance. It never substitutes its own parser for proof-tool at a signed + boundary. +- **Tessera** optionally delivers typed temporary grants, exact expected attempt + notifications and recent checkpoint confirmations. Those messages are hints + or private access, not ceremony acceptance. +- **Storage configuration** owns provider permissions, public/private separation, + caching and retention. Relay preflight checks the properties it relies on. + +## Definition of done + +The redesign is complete only when: + +- every connected role can infer its authenticated public position and next + required action from storage plus its local identity, trust bootstrap and any + necessary private/local facts; +- no normal connected-role step asks the user to copy individual public files + or choose an internal destination path; +- every claimed completion is backed by the exact authenticated artifact that + defines it; +- stale-cache, concurrency, partial upload and interruption tests pass; +- the offline fallback binds both directions to one exact checkpoint; +- the final approval authorizes only the exact files reviewed; and +- a clean released-version ceremony succeeds without hidden fixture handoffs; +- both a fully enabled ceremony and a ceremony with every optional assurance + control disabled succeed without hidden role work; and +- every omitted optional role is justified by an explicit zero in the signed + definition and displayed as `NOT_REQUIRED` at final review. + +## Explicit limits + +- Correctness of the coordinator and configured storage service is assumed; + signatures do not independently establish their honesty. +- Relay verifies distinct keys, not independent people or organizations. +- Docker cleanup and participant confirmation do not prove physical erasure. +- A compromised role machine can misuse that role's key or active temporary + grant. +- V1 supports one state-changing coordinator workspace. It does not provide + automatic coordinator failover, safe concurrent mutation from cloned + workspaces, or protection from a malicious host administrator. + +## Historical review record + +These notes describe earlier iterations and tests of their implementation. +References below to hostile storage, envelopes, acknowledgements and independent +freshness services are historical; the trust boundary and current rollout plan +above supersede them. They are not claims that the simplified code is complete. + +The first independent review found that a loose unsigned catalog could combine +valid artifacts from incompatible snapshots. This revision therefore uses one +mutable hint pointing to an immutable signed checkpoint chain. The review also +added digest-aware rollback protection, a single-writer coordinator rule, +provider-tested conditional writes, bounded exact-prefix inbox retrieval, a +pre-initialization draft lane, protected release staging, explicit observer +assignments, witness timing rules, +dependency-graph guidance and a narrower statement of what storage can infer. + +The second independent pass separated ancestry from freshness, strengthened the +bootstrap digest, split receipt and candidate grants, replaced inbox discovery +with coordinator-preallocated exact inbox prefixes, defined legal checkpoint +transitions and permanent ancestry, added a setup-complete bridge, witness +readiness, typed protected-read/promotion grants, a frozen review checkpoint, +closed-world publication and a coordinator-verified publication checkpoint. It +also expanded the crash model so signing, upload, conditional commit and local +high-water persistence cannot collapse into one falsely atomic action. + +A later usability review removed mandatory expiring freshness notifications. +Tessera performs an authenticated on-demand status lookup; standalone V1 +rereads verified storage and preserves digest-aware rollback history. The design +now explicitly discloses the first-use stale-view risk. A nonce-bound live +confirmation remains an optional stricter policy, not part of the normal CLI +journey. + +The submission-transport review then removed duplicate participant submission +envelopes and coordinator acknowledgement records under the explicit +honest-coordinator assumption. It retained checkpoint-allocated attempts and +scoped grants, made the unsigned manifest a strict transport-only completion +marker, required proof-tool to derive accepted canonical artifact names, and +added rejection, restart, substitution and manifest-corruption cases to the +test plan. + +A fresh three-round restart review then added sequential verification of every +missing checkpoint, durable high-water state before mutation, staged coordinator +signature intents, idempotent grant recovery, separate attempt and credential +lifetimes, explicit same-workspace restart limits, review cancellation, a +restricted outage fallback, role-specific setup-complete entries, and an exact +parent/root-version check for every conditional root update. The final targeted +checks found no remaining blocker-level contradiction. This is a design review, +not an implementation test or security certification. + +### Implementation adversarial review + +The first implementation review rejected shallow checkpoint sync, unscoped +local completion flags, ambiguous accepted candidates, unrelated root children, +and guidance that could skip the end of a participant schedule. The +implementation now downloads the signed evidence inventory, requires +proof-tool's full stored-evidence result before advancing local high-water +state, binds local facts to exact checkpoints/attempts/digests, compares exact +candidate acceptance, and conditionally publishes only a deeply verified child +of the exact current root. + +The second review found and fixed an artifact-root path mismatch, replay of a +candidate submission across sibling allocation checkpoints, duplicate attempt +or manifest names, grants not bound to an authenticated slot, excessive grant +lifetimes, shallow projections driving guidance, and cross-platform filename +collisions. Storage-first artifact names are consequently portable lowercase +ASCII. The revised design uses the existing participant-signed records rather +than a duplicate submission envelope; temporary grants last at most one hour +and must match a fully verified slot, and only a checkpoint returned by full +sync can drive role guidance. + +The review also confirmed that the current code is a protocol foundation, not +the completed journey described above. Production remains unavailable until a +real Relay coordinator/participant path performs cp0 through cp3, rejected or +damaged submissions can be retired and replaced, coordinator journal recovery +is integrated, returning workspaces verify incrementally from a durable trusted +anchor, and live S3/R2 conditional semantics pass. The small TLA+ module is +only an ordering sketch; it is not evidence that those implementation +properties hold. + +A final implementation pass found three additional failures and added +regressions for each. Production-size Phase 1 contributions are now verified +with streaming digest checks rather than the 16 MiB operational-record limit; +small JSON records and signatures retain their strict bounds. Workspace +high-water updates now lock the complete read/check/write transaction, so a +delayed synchronization cannot overwrite a newer authenticated checkpoint. +Finally, a root conditional write is not reported as committed until a bounded +reread returns the exact intended bytes and version; restart reconciliation +distinguishes the intended root, the unchanged prior root, an unexpected root, +and an unreadable ambiguous result. The proof-tool command regression now +drives cp0 through cp3, requires full stored-evidence verification, and rejects +independent corruption of the candidate, transport manifest, participant +signature and accepted chain. + +### Optional-role design review + +Three adversarial passes reviewed the policy that permits zero witnesses, +mirrors, ceremony audits and external security-audit signoffs. The first pass +corrected an overclaim about no-witness beacons: without an independent witness +or a separately trusted commitment timestamp frozen in advance, a malicious +coordinator can backdate a closure after learning the beacon. It also made the +definition the sole policy authority, required explicit empty evidence arrays, +bound checkpoint and capsule projections back to the definition, made +`NOT_REQUIRED` deterministic, and froze external-audit evidence into review. + +The second pass separated core enrollment, witness readiness and mirror timing. +Core enrollments gate the first turn; witnesses gate an enabled closure; mirrors +reserve phase/index slots before candidate acceptance and later sign the exact +accepted head. It also made external security audits production-only, excluded +unused draft invitations from ceremony authority, and prohibited retroactive +timestamp assurance. The final pass found no remaining material contradiction +or downgrade path. + +The remaining tradeoffs are explicit product choices: all-zero production has +no independent publication-timing, retention, ceremony-replay or external- +review assurance; mirror retention is eventual before final review rather than +turn-blocking; the coordinator selects post-initialization observer identities; +and standalone first-use freshness still depends on the configured provider. +The recommended UI default keeps the current nonzero assurances while allowing +the coordinator to choose zero deliberately before signing the definition. diff --git a/docs/maintainer/trusted-services-implementation-plan.md b/docs/maintainer/trusted-services-implementation-plan.md new file mode 100644 index 0000000..072a536 --- /dev/null +++ b/docs/maintainer/trusted-services-implementation-plan.md @@ -0,0 +1,897 @@ +# Trusted coordinator and storage: next implementation + +Status: implementation in progress, September 16, 2026. This document is not +release evidence or authorization by itself. It follows the +[storage-first design](storage-first-ceremony-design.md) and +[release verification model](release-verification-trust-model.md). + +The trusted coordinator/storage review removed custody receipts from the next +format. The normative replacement is +[Trusted-storage ceremony V4](trusted-storage-v4-design.md). Definitions and +Checkpoints V1–V3 are released compatibility formats and remain unchanged. The +earlier receipt-based V4 draft is unreleased and will be replaced rather than +preserved. + +## What we trust + +The coordinator follows ceremony rules and runs mathematical verification. +The configured storage service returns committed state and stored objects +according to its API. Participant inputs still require verification. +Ordinary failures, incorrect local files and concurrent processes remain in scope. + +## Implementation progress — September 16 + +Normal V4 guide startup now authenticates and binds saved profiles, opens its +separate journal, and refreshes backend stage/turn metadata. This read-only +view does not execute turns; connecting those actions remains the next task. +Independent review corrected phase-specific participant handling and kept +pending local work visible when storage is unavailable. Legacy guides are unchanged. + +The normal participant guide can now fetch receipt inputs for its active turn +from exact committed references. Fresh staging has no operation-completion marker; +after interruption, another read-only fetch is safe and does not overwrite earlier +copies. SHA-256/size checks are transport checks; proof-tool still checks signatures +and both signed digests before receipt signing. This is not a computation-ready +Phase 2 transcript. Receipt signing, its later dependency staging, and production +large-object transfer timeouts remain required integration work. + +Current boundary: proof-tool's V4 library and explicit CLI now cover checkpoint +authoring, checkpoint-bound bundle preparation/signing, final review, package +signing/verification and Decision V3. These changes are pushed, not released. +The evidence CLI slice is `5c143c5`; full Linux ceremony/CLI suites and vet pass. +Its real tiny artifact test invokes the approved CLI and rejects a modified +executable before output/key access. This is not a normal-role storage journey +or a production K21 approval test. Those, provider tests and releases remain. + +The entries below record implementation stages, not independent release claims. +The initial compatibility/transport slice established: + +- Proof-tool's released schema inventory is in + `docs/ceremony-schema-compatibility.md` in that repository. Existing V3 + behavior is pinned to explicit version checks before adding a new default. +- Removed the uncommitted backend-object-key output experiment from proof-tool. +- Relay has bounded unsigned delivery framing, private staging, manifest-last + upload, exact-byte retry and all-or-nothing fetched staging. This does not + authenticate participant records or record acceptance. +- Large-artifact hashes are streamed rather than loaded entirely into memory. +- Proof-tool's opt-in Definition V4 now makes the coordinator-replay claim + explicit. Default construction stays V3 until the complete new path is ready. +- Checkpoint V4 has a separate transport-neutral state model and structural + transition tests through both phases and release. It removes delivery + envelopes/acknowledgements without relaxing any released checkpoint schema. +- A complete, normalized per-turn `contribution_result_id` distinguishes + rejected contribution bytes from a retired upload. Attempt IDs and paths are + not part of that identifier. Terminal dispositions remain in bounded history. + +The next library slice authenticates stored checkpoint ancestry and prepares +initial/turn/retry checkpoints against actual files. Acceptance replays the +chain and requires the normalized inventory and verification record to match +the exact replayed chain artifacts. Large payload hashing remains streamed. +Stored-state inspection verifies signatures and structural ancestry; it does +not claim to rehash every historical payload or establish global freshness. + +The semantic review caught a mismatch between a candidate inventory and the +artifacts covered by chain replay. Exact-reference checks and per-file negative +tests close it; the follow-up review found no remaining material issue in the +implemented edges. The Linux ceremony/CLI suites and vet pass for this slice. +A Linux subprocess test now exercises a real tiny Phase 1 contribution through +the new authoring API: initialization, outbound handoff, receipt delivery +retirement/reallocation, signed receipt, contribution, cleanup attestation, +mathematical replay and exact acceptance. Same-size payload corruption is +rejected. Independent review found no material false-positive coverage or +ordinary-helper regression. This is single-process evidence with fixture +environment/cleanup statements, not separate role journeys, cloud transport or +proof of erasure. Unsupported release authoring deliberately fails closed. + +Still required: final-release and production-decision authoring, +the new signer verification path, normal role-menu integration, full role +rehearsals, live-provider testing and release/deployment. No existing ceremony +is switched to the new transport by these foundations. + +Enrollment and mirror evidence now have typed V4 checkpoint edges. Historical +enrollments are re-authenticated with their disclosures; outbound delivery +requires the participant's committed enrollment. Mirror evidence is counted per +exact accepted head using enrolled keys, and phase closure checks every head. +The real tiny subprocess passes with mirrors disabled and with one mirror +required; missing enrollment and missing mirror evidence are rejected. The +review closed a disclosure-size mismatch by sharing the existing 1 MiB bundle +limit and bounded new observer indices. Audit gates and +the release path remain incomplete; this is not readiness to publish V4. + +Witness evidence has a typed edge when witnesses are enabled. The ordinary +signed phase-beacon record binds one raw drand response to the committed future +round and is sufficient after proof-tool verifies the drand signature. Relay +tries a second endpoint only when the first is unavailable or invalid; it does +not create a separate two-operator evidence record. Witnesses are optional and +are not part of the initial storage-first role journey. + +Final-candidate preparation now derives replay inputs from the authenticated +predecessor checkpoint, replays both phases through the existing full verifier, +and binds its complete closed file inventory. Its versioned replay claim names +the actual approved executable. The tiny subprocess now reaches Phase 2 +contribution, custody, optional observers, the second genuine historical drand +round and finalization with both observer settings. The first run caught a test +witness mismatch between the older helper circuit and the released tiny circuit; +the helper now constructs the correct witness for the selected circuit. +Independent review found no blocker and requested one shared versioned replay +method constant, now used. Tests reject missing/wrong replay claims, an omitted +candidate reference, an extra candidate file and signed Phase 2 closures with +reused/older rounds or invalid cross-phase timing. Both Linux ceremony/CLI +suites and vet pass; the final helper extension passes separately. This is not +final release-signing or menu coverage. + +Audit collection now has a typed edge after the frozen candidate. A shared +byte-based verifier authenticates every audit while postponing only the minimum +count check during collection. Final release enforces that count; disabled +audits stay forbidden. Records are read through the confined checkpoint reader +and require committed auditor enrollments. The Linux helper performs two real +auditor replays and rejects a missing enrollment; shared tests reject a partial +release quorum, duplicate auditor, wrong candidate and bad signature. Both +Linux ceremony/CLI suites and vet pass for this slice. +The implementation review found no blocker. Release assembly must sort audit +pairs deterministically rather than use reverse ancestry order, and must verify +the complete required enrollment collection through the existing bundle gate. + +Operational-bundle preparation now derives the unchanged v3 bundle exclusively +from the authenticated checkpoint history and runs its existing verifier. The +Linux tiny tests cover both observer settings and enabled audits, require every +roster enrollment (including a participant who did not contribute), ignore valid +but uncommitted files, produce deterministic output, and reject corrupted +custody, chain-prefix and raw beacon bytes. Both ceremony/CLI suites and vet pass. +Independent review found no blocker; final-release verification must still +rederive against its exact predecessor, because source-checkpoint metadata is +not embedded in the legacy signed bundle. This does not verify the final proof, +sign the bundle, or complete the release path. + +Governance review found that abort/restart cannot be ordinary evidence edges: +the unchanged bundle verifier authenticates them but does not stop progression. +Implement informational incidents separately. Dedicated abort/restart handling +must terminate the old ceremony; restart must additionally bind and authenticate +the exact new definition. Do not route generic rejection records around the +existing exact contribution-result rejection mechanism. + +Those V4 governance library gates are now implemented: incidents preserve state, +abort/restart produce an immutable terminal marker, and active delivery history +is retained without waiting for credential expiry. Only the coordinator may +authorize them; restart verifies an exact new V4 definition. Existing record +schemas remain unchanged. Independent review caught a historical scope recheck +gap: ancestry inspection now verifies each governance edge against its exact +predecessor, including a manually signed wrong-head regression. Full Linux +ceremony/CLI suites and vet pass. Production GO/NO-GO and normal CLI guidance +remain separate required work; these gates alone are not a completed release. + +The read-only final-review API is implemented; normal V4 release guidance remains off. +It binds the exact review checkpoint and approved coordinator replay claim, +authenticates/derives both phase summaries, verifies a closed candidate tree, +rederives the signed bundle and enforces checkpoint-derived audits/chronology. +Review caught and fixed dependence on the local trusted-definition filename. +The signer checks native/Cardano export coherence and verifies the public proof +without regenerating setup keys or replaying contributions. Legacy V1–V3 gates +remain unchanged. Full Linux ceremony/CLI suites and vet pass; the added negative +test coherently re-signs metadata/checksums around an invalid public proof and +confirms the V4 proof check rejects it. This read-only API does not sign a release, +establish backend freshness, or replace the remaining role-menu/cloud tests. + +The independent structural review found and closed a retry-limit deadlock and +an ambiguous predecessor-signature boundary. The final review reported no +remaining material finding in this slice. Proof-tool's Linux ceremony/CLI +suites and vet pass; focused structural tests also pass with the race detector. +The bounded retry model reproduces the old deadlock and passes after the fix. +These results do not establish the still-unimplemented normal CLI journey. + +## Existing verification versus planned changes + +### Completion review: remaining protocol gates + +The independent lifecycle review identified requirements that must be completed +before V4 can ship: + +- Acceptance must retain the existing return handoff and coordinator return + receipt, delivered through storage automatically. Accept only the seven-file + candidate form; five-file inventories remain useful for rejected candidates. + Bind the return receipt into checkpoint evidence and check custody chronology. + A coordinator-receipt error is not rejection of the participant's candidate: + preserve its result ID, correct the receipt and retire/reallocate delivery if + needed. Never permanently reject otherwise-valid candidate bytes because a + coordinator-created receipt was missing or malformed. +- Add typed evidence-recorded edges for enrollments, optional mirror/witness + receipts, audits and applicable governance. They preserve + protocol progress and deliveries. Derive readiness from authenticated ancestry + rather than storing duplicate mutable counters. Do not count disabled controls. +- Preserve cross-phase beacon separation at checkpoint preparation, not merely + at final release. Exact-read the preceding Phase 1 records; reject reused + rounds/challenges and inconsistent chronology. Test with a signed bad closure. +- The review's enrollment-ceiling concern was withdrawn after checking the + actual constants: 20 participants and 20 per auditor/observer category yield + at most 82 required enrollments, below the existing 128 limit. A regression + assertion guards that bound; no bundle-schema change is needed for capacity. + +Gate outbound delivery on participant enrollment, closure on each enabled +head-mirror minimum, sealing/final-candidate preparation on any enabled witness +minimum and the ordinary signed beacon, and release signing on enrollment, +bundle and any enabled exact-candidate audits. +External security audits remain part of the later production decision. Reuse +operational bundle v3 and existing signed records where their meaning is unchanged; +do not add another participant envelope or manual transfer step. + +The local lifecycle test reaches Phase 2 genesis with a genuine historical drand +response. It is not proof of these remaining evidence gates or live beacon timing. + +Coordinator mathematical replay is not a new requirement. Both +`PrepareFinalization` and `Finalize` already required `replayAll` in proof-tool +commit [`c1f177e`](https://github.com/zksecurity/proof-tool/commit/c1f177ee486fd555fac0dc4d9812b86737fccdfd), +dated July 31, 2026. PR #31 (September 15) added mandatory independent +release-signer replay for the new storage-first flow. + +Preserve the existing coordinator replay; remove only the additional mandatory +signer replay in the new format. The signer still checks exact files, signatures, +policy and the coordinator's bound verification statement. The release-signer +role remains required; making the role optional is not part of this plan. + +| Area | Target | Current implementation gap | +| --- | --- | --- | +| Coordinator replay | Preserve existing mandatory verification | Preserve replay and its exact-file binding in the revised review/release path | +| Release signer | Check files, signatures, policy and coordinator claim; replay optional | Explicit V4 signing/verifying exists; normal Relay flow pending. Released V3 still requires signer replay | +| Participant upload | Existing signed receipt, contribution and cleanup records | V4 protocol exists; Relay's normal journey still uses the old envelope flow | +| Acceptance | Checkpoint commits outcome and exact artifact references | V4 authoring verifies this; normal Relay integration pending | +| Storage progress | Current provider root drives normal CLI | Advanced sync exists; normal role journeys incomplete | +| Retry | Redeliver identical valid files; candidate rejection persists by digest | V4 library semantics tested; normal role delivery/recovery integration pending | +| Recovery | Preserve unfinished mutations; reconcile provider result | Reusable foundations exist; normal flow integration incomplete | +| Component boundary | Proof-tool verifies logical artifacts; Relay maps storage | Backend-object-key experiment removed; new normal workflows still need integration | + +## 1. Resolve the schema and API together + +Inventory released identifiers before choosing a new version. Proof-tool PR #31 +already shipped storage-first schemas with envelopes and mandatory signer replay. +Preserve that behavior for existing ceremonies. A new schema/version must express +the simpler rules; missing old fields must never silently enable them. + +First implementation gate: check in a compatibility table of every released +definition/ruleset, checkpoint, operational bundle, final transcript, release +manifest, decision and Tessera setup contract. Record each one's envelope, +acknowledgement, optional-role and signer-replay rules. Do not assume all older +schemas require minimum-one roles; some optional-role behavior already shipped. + +That inventory is now recorded in proof-tool's +`docs/ceremony-schema-compatibility.md`. The next version set is Definition V4, +Checkpoint V4 and `storage-first-v2`, with final transcript V3 carrying the +explicit changed release claim. Keep application key manifest V1: its signed +transcript hash binds that claim. No additional release signature is needed. +Decision V3 is required for Definition V4/transcript V3; released Decision V2 +keeps its original behavior. Explicit proof-tool initialization can opt into V4, +but Relay's normal initialization does not yet select it. +Do not release the partial V4 library support before the complete path is tested. + +Downstream setup baseline: setup-v2/two-phase-v1 (two auditors), +setup-v2/two-phase-v2 (one auditor), and setup-v3/two-phase-v3 (explicit optional +assurance). Frozen setups keep their exact ruleset and contract hash. Before +V4 rollout, replace version-3-only capability checks, add V3 to the permanent +compatibility matrix, and preserve positive external-audit policy values rather +than silently forcing them to zero. New defaults activate only after the +compatible release is provisioned. + +Rework draft proof-tool PR #32 around these operations: + +- receive existing participant-signed records and validate exact ceremony, + phase, turn, participant, predecessor and closed payload inventory; +- derive logical accepted artifact names inside proof-tool; +- prepare/sign a checkpoint accepting those exact records, without a separate + participant envelope or coordinator acknowledgement; +- record delivery retirement separately from candidate rejection; +- verify releases without contribution algebra, relying on the coordinator's + signed final-candidate checkpoint for that algebra. + +An acceptance helper may wrap existing chain verification/signing commands. +Specify whether it creates the accepted chain or consumes one before +implementation; never accept a prebuilt chain without verifying its candidate +bytes and predecessor. Return logical names, hashes, sizes and local output +paths. Remove the uncommitted object-key experiment. Provider prefixes, bucket +names, object keys and manifests belong to Relay. + +Inventory existing `relay_release_id`, `manifest_key` and slot-prefix fields: +the current schema already couples these to proof-tool. For the new API, +prefer opaque attempt IDs and protocol identities; Relay owns the authenticated +delivery mapping. Define its signature and ceremony/checkpoint binding before +moving those fields, so no scope is lost accidentally. + +Prefer deterministic prefix/manifest mapping in Relay's storage contract, +with the approved Relay release in bootstrap/Tessera setup and approved +proof-tool executable identities in the definition. Proof-tool receives no +bucket names or backend paths. Reject inconsistent mappings before transfer. + +## 2. Complete one real participant turn + +Keep draft Relay PR #52 as the integration branch. Start with: + +1. Coordinator publishes a turn and allocates receipt delivery access. +2. Participant syncs, downloads inputs and signs the existing receipt. +3. Relay uploads the receipt/signature and unsigned manifest. +4. Coordinator verifies it and commits the receipt-accepted checkpoint. +5. Participant contributes, confirms cleanup and uploads signed candidate files. +6. Coordinator verifies the mathematics and commits candidate acceptance. +7. Participant sees its exact candidate accepted after restarting its CLI. + +Acceptance requires exact signed records and the active slot. Attempt retirement +permits redelivery; candidate rejection records its digest and blocks reuse. +Do not attribute upload-attempt consent to the participant. + +Define that candidate ID over the complete scoped artifact set, including +signed attestation, cleanup and required return evidence—not only the large +contribution file. Reuse an existing canonical ID only after checking its +coverage. Changed candidate contents need fresh verification and disposition. + +The new inventory uses fixed basenames and exact digests, never an +attempt-dependent path. The public field is `contribution_result_id`, avoiding +confusion with the final ceremony's existing `candidate_id`. Signature, scope, +custody and mathematical validity are still checked separately before acceptance. +Retirement/rejection can finish without a replacement, or allocate one without +advancing the turn. A later replacement names the most recent retired/rejected +attempt. An exhausted retry budget must not prevent terminal retirement. +History permits at most 16 attempts per logical submission and 4,096 total +delivery slots across the ceremony; these are separate per-submission and global +budgets. Exceeding a bound fails explicitly, never discards a rejection. +Only the current active allocation can change, one allocation can be active per +submission, and acceptance is terminal. These are protocol bounds, not a reason +to retry automatically. +After terminal retirement, closure is permitted only when the signed minimum +has already been met. Otherwise work remains incomplete; no success is inferred +from giving up on an upload. The signed-edge verifier compares both predecessor +record and signature digests, beyond the structs-only transition checks. + +Use fixed transport inventories for each artifact kind and derive public logical +names from proof-tool results. Transport manifest validation never establishes +acceptance or authorizes extra public files. + +## 3. Simplify sync without breaking recovery + +Reviewed sync bridge (implementation in progress): authenticate a separate +`inspect definition-protocol` projection before selecting the exact V4 / +`storage-first-v2` / `coordinator-full-replay-v1` tuple. Never interpret an +inspection failure as permission to fall back. Existing pinned tools keep +their old command path; ordinary `inspect definition` output stays unchanged. + +For V4, `checkpoint inspect-signed-v4` authenticates one pair and reveals only +its predecessor, bounded governance dependencies needed by stored verification, +and a separately labelled enrollment pair for guidance. This discovery result +is not usable ceremony progress. Stage the complete ancestry and enrollment +pairs, then call `inspect-enrollments-v4` once for both structural verification +and enrollment metadata before recording local high-water state or returning +a usable snapshot. Structural-only callers can still use `verify-stored-v4`. +Do not fetch cumulative +contribution payloads during sync. The sequence limit is 16,384 (16,385 records +including genesis), not the legacy sync cap. Fresh-copy contract tests verify +that the discovered dependencies suffice and each required missing file fails. + +The proof-tool discovery slice is pushed in `9841387` with command coverage in +`47ba50e`. Full Linux ceremony/CLI suites and vet pass; the strengthened +dependency-only, format-dispatch and CLI tests also pass in Linux. +The Relay adapter and separate V4 synchronizer pass targeted tests, including +partial high-water persistence and restart; normal menus are not connected yet. +The next role recommender must use progress, delivery +history and committed evidence—not the last transition name, because unrelated +evidence may arrive during an active participant turn. Derive evidence facts +from verified ancestry and label coordinator commitments separately from +independently reverified record contents. Keep the existing mutation journal, +exact-byte uploads and conditional root commit; no automatic reparenting. + +The reviewed record-index slice keeps all outbound packets for each exact turn, +newest first, plus its accepted input receipt and accepted chain/result. An +accepted receipt may name an older packet; its signed hash selects that packet, +not the newest publication or current upload attempt. An unrelated enrollment +edge and delivery reallocation do not erase prior commitments. +The batch enrollment inspection verifies exactly the checkpoint's +committed enrollment set and signed disclosure references. Disclosure contents +and roster completeness are not part of this check. One ancestry walk returns +separately labelled structural and enrollment results. If download or metadata +verification fails, high-water is not advanced and no guidance-capable snapshot +is returned. Interrupted persistence after successful verification can resume. +Returned facts are immutable copies. +Normal role actions and a full storage-backed journey still remain to implement. +The index/metadata slice passes the full Relay Go tests and vet, plus focused +race tests. Proof-tool's Linux core and actual CLI suites and vet pass, including +nonempty enrollment inspection and missing/changed signature failures. Tests +cover retained older outbounds, reallocation, exact-set/head comparisons, +metadata failure/retry and single-command combined verification. These are +component/CLI checks, not a completed storage-backed role rehearsal. + +Remove requirements for challenge responses, independent freshness services +and hostile-provider history reconciliation from the new flow. + +Keep provider root reads, signatures, hashes, local locks, conditional writes, +size limits, safe paths, immutable uploads and durable mutation intents. +These prevent errors and duplicate work even with trusted services. +Do not delete a journal or skip a validation merely because it was originally +introduced during a broader threat-model review. + +Split mathematical replay from ordinary progress verification explicitly: +opening a CLI should validate records and state without replaying every large +contribution. Coordinator acceptance/finalization and enabled auditor replay +perform the mathematics. Cache verified records for efficient restart. + +## 4. Complete the roles and final release + +### Reviewed next slice: normal V4 turn guidance + +Use authenticated definition requirements and the verified V4 snapshot, not +the latest transition name. Start with coordinator/participant turns in both +phases; unsupported closure, beacon, final and terminal actions must remain +explicitly unavailable rather than falling back to legacy guidance. + +- Opening an outbound turn requires that scheduled participant's committed + enrollment, matching proof-tool. Other missing enrollments remain visible as + parallel work; do not invent an all-roster gate before every contribution. +- Protocol artifacts bind the exact ceremony/phase/index/participant/parent + and their own content hashes. They survive unrelated checkpoint updates and + transport retirement. Grants and uploads also bind the exact attempt. +- Pending checkpoint writes retain their exact predecessor and root version. + An unrelated update can preserve the turn while invalidating that proposal; + never silently reparent a signed write. +- Before a receipt exists, suggest the newest outbound packet. After signing + or accepting a receipt, follow its signed handoff hash, including an older + valid packet. Only signed checkpoint receipt acceptance permits computation. +- Retired uploads can reuse exact retained protocol artifacts on a new attempt. + A rejected candidate result cannot be reused; show its rejected state and + require explicit investigation/new work. Never recompute automatically. +- Completion compares the accepted chain's contribution-result ID with the + exact local result. A different result or missing local result is not local + completion. Earlier participants see their own accepted/rejected turn, not + the current participant's task. +- Re-sync before mutation and check scope, attempt, closure and terminal state. + Recommendation is not authorization; command verification and conditional + root updates still enforce the operation's exact inputs. + +Tests must branch through unrelated enrollments, old-packet receipts, receipt +and candidate reallocation, rejection, same/different-result acceptance, grant +expiry, abort/restart, previous participants reopening, missing metadata and a +changed root between recommendation and execution. The independent review +removed an unnecessary all-roster gate and separated artifact, upload-attempt +and checkpoint-write bindings before implementation. + +The turn model and pure recommender are implemented for both phases. They are +not yet connected to the normal menu/executor or durable V4 fact reconstruction. +The code review added two recovery checks: every recorded upload must match +the exact retained delivery kind/artifact, and coordinator acceptance explicitly +follows download/check → sign return receipt → verify/accept. A replacement +candidate attempt requires comparing the newly received submission before +reusing an existing result/receipt. Tests cover missing local result facts, +unknown/mislabelled uploads, historical receipt uploads, replacement deliveries, +grant expiry, rejected results and exact-result completion. A pending operation +must be surfaced from the durable journal before selecting ordinary turn work; +never manufacture empty local facts by ignoring a reconstruction failure. + +The participant's computed inventory contains five files. The final upload +inventory contains those same five plus the signed return handoff, so the two +IDs differ. Reconstruct both in one proof-tool inspection; verify that the +return handoff names the exact computed files. Five files mean prepare/recover +the return packet, never compute again. A partial pair or final ID without +verified computation facts means inspect retained work. Upload, rejection and +acceptance comparisons use only the final seven-file ID. Coordinator downloads +may have a final ID without a separately retained computation-stage marker. +Inspection must match the expected turn, preserve signature/software/chronology +checks, and make no mathematics, freshness or physical-erasure claim. Rehash +the returned closed inventory at upload; inspection does not freeze file paths. + +### Reviewed execution and restart integration + +Select `workflow-v4/state.json` only after authenticating the V4 definition; +V1–V3 keep `workflow/state.json` unchanged. Bind the new state to the exact +definition pair, ceremony, workspace and role identity. Reuse atomic saves, +workspace locking, input hashing and coordinator commit durability—not legacy +catalog stages, phase1-only local facts or generic retry dispatch. + +Persist a bounded operation record before launching anything: local operation +ID and closed action kind, exact turn/predecessor, original runtime/mounts, +command, inputs and expected output paths. Delivery attempts are separate from +local operation IDs. Scope-only computation and return signing survive delivery +reallocation; grants and uploads do not. Keep immutable predecessor copies and +attempt-specific download directories rather than mutable “current” paths. + +Hold the workspace lock across loading, sync, recommendation and execution. +Only a prepared operation is known not to have launched. Running, cancelled or +failed children remain uncertain until exact output inspection. Saved success +is a locator, not proof: reverify artifacts before deriving guidance. A sync +failure may allow local inspection of retained work, never new ceremony work. + +Coordinator actions also retain the existing commit-journal link: child success +does not mean publication. Confirm the exact authenticated child and root CAS; +on a conflict, sync and inspect without reparenting the signed child. Participant +upload success means only uploaded files/manifest, not contribution acceptance. +Test crashes at each save/launch/output/upload/CAS boundary, backend advancement, +grant expiry, partial five/seven-file output and isolation from legacy journals. + +The journal foundation is implemented in `cmd/relay/workflow_v4_journal.go`; +normal menu execution is not connected yet. Its separate create-only workspace +marker binds the exact state location, definition pair and role. The state is +limited to 16 MiB and 8,192 operations; legacy atomic saves retain their 1 MiB +limit. Strict reading rejects duplicate/unknown fields and non-private files. +Failed saves disable the current handle, because a rename may have succeeded +before fsync reported failure. Reopening reads the durable status. Prepared +operations can be abandoned only with no declared outputs; running work requires +exact-result inspection. Coordinator reconciliation also checks the complete +saved publication plan and committed conditional-write status, in addition to +the caller's live artifact/publication verification. These local records are +not cryptographic proof or permission to skip revalidation. + +Review tightened the journal's integration contract: its header also pins the +approved runtime profiles, and opening checks the role against authenticated +roster metadata plus the exact authenticated definition pair. Computation and +record-signing commands must match their kind, retained input/output paths and +reviewed record hash. Receipt downloads are a separate attempt-bound operation. +Publication journals use `workflow-v4/commits/.json`, and their +initial output list must equal the outer operation's output list. Network and +coordinated-publication handlers use fixed internal dispatch tokens, not a shell +command. Those handlers remain to be implemented: they must consume only the +saved plan, and persist each actual checkpoint/signing child command before it +runs. The local journal tests do not establish that handler integration. +Before menu wiring, replace generic reconciliation callbacks with closed +per-kind artifact/publication reconcilers and test every handler's interruption +boundaries. A second independent review found no remaining material defect in +the journal foundation; it did not review those still-unimplemented handlers. + +### Incident and termination records + +Use separate V4 transitions for an informational incident, abort and restart. +All three reuse governance records but additionally require the coordinator's +identity and signature (the generic legacy governance verifier also allows +other roles). The record names the exact current phase and head. Its legacy +one-based event index is the accepted count, or 1 at genesis; it is not an +assertion that a genesis contribution was accepted. The checkpoint signature +binds the exact predecessor and record as the current authorization; record time +is the time of the statement, not proof of current backend freshness. A record +already committed cannot be added again. + +The transition includes exactly the record's reviewed evidence and hashes those +bytes. Incident/abort evidence is one UTF-8 public statement (at most 1 MiB), +whose hash equals the statement digest. Restart evidence is exactly that +statement plus the new definition and signature. Every selected file becomes +permanent public evidence: never select logs, keys, credentials, environment +dumps or private submission data. All reads are confined to public staging; +no files are discovered automatically. +Informational incidents do not advance the phase or change delivery history; +the automatic operational bundle includes every committed incident. + +Abort and restart set a typed terminal progress marker naming the kind, signed +record and (for restart) exact new definition pair. The enclosing checkpoint +binds its exact predecessor. Every later +transition is forbidden. Existing delivery history remains intact, including +unfinished attempts: protocol termination must not wait for a grant to expire. +Relay must separately explain that a previously issued cloud credential may +remain usable until revocation or expiry, but can no longer authorize ceremony +acceptance. Do not silently convert an abort into a restart. + +Restart additionally names the exact new signed definition and verifies its +signature, distinct ceremony ID and V4 schema (no silent downgrade). The old coordinator's signed checkpoint +approves that exact pair; the new definition's own coordinator signs it. Creating +the new ceremony is a separate action, not an automatic mutation of old files. +This is an authenticated old-side pointer: the bare new definition does not +prove restart lineage. Roles must retain and verify the old terminal checkpoint +to recognize the new ceremony as its authorized restart. +All three record types are unavailable after final release has been recorded. They +are not substitutes for the separately authenticated production GO/NO-GO path. +Terminal progress cannot prepare an operational release bundle or final release. +Existing formats keep their unchanged governance meaning. + +Stop authoring verifies its own bounded evidence, not unrelated contribution +payloads or the full enrollment collection: missing data must not prevent a +coordinator from stopping. Statement time cannot predate the old definition; +the restart definition must already exist at that time. These are consistency +checks, not a trusted clock. Historical inspection rechecks every governance +edge against its exact predecessor while walking the signed history, avoiding +copied growing inventories. A manually signed wrong-head edge is rejected too. + +Tests must cover wrong signer/head/phase, mismatched statement/evidence, missing +or changed restart definition/signature, reused ceremony ID, abort with an +active delivery, every attempted transition after termination, and automatic +inclusion of committed incidents. Production NO-GO-before-finalization remains +a separate required release/decision slice; this change does not claim it works. + +### Automatic operational-bundle assembly + +Derive the existing bundle-v3 record from an exact authenticated V4 checkpoint, +not user-maintained file lists. Require a frozen final candidate and no final +release. Sort every signed-record collection by logical record name. For each +accepted turn, use its exact committed chain prefix, input receipt and original +handoff, accepted return handoff/receipt, and matching mirror records. Use the +committed closures, ordinary signed beacon and its single verified raw drand +response, plus witness records only when witnessing is enabled. +Include the complete committed enrollment collection, including roster members +who did not contribute in a threshold rehearsal. Run the existing unsigned +bundle verifier before returning anything for review/signing. This operation +does not sign, upload, or repeat contribution mathematics. + +Audit collection remains separate from this unchanged bundle format; the final +signing gate enforces its full minimum. Missing custody or enrollment records +must produce a specific missing-evidence error, never a generated replacement. +Committed incidents are included; a terminated checkpoint cannot prepare a +release bundle. V4 remains unreleased until the complete release path works. + +### Exact final review, without duplicate contribution replay + +First add a read-only V4 review verifier, before enabling release signing. Its +inputs are the independently trusted definition, public staging root, exact +review checkpoint pair, exact coordinator-signed operational bundle pair, and +proposed release time. No caller-supplied audit list or alternate phase paths. + +It authenticates the complete checkpoint ancestry, rejects terminated/released +state, finds the committed final-candidate checkpoint and its approved-binary +coordinator replay claim, and checks the exact candidate files with the existing +non-replay candidate verifier. Its closed file inventory must equal the one +committed by final-candidate authoring; extra files and substitutions fail. +Share the existing closed-tree verifier (including its second verification after +the walk), rather than duplicate its filename list. Authenticate and validate the +exact checkpoint-referenced phase chains, closes, beacons and Phase 1 seal; apply +the existing cross-phase round/timing rules and derive both phase summaries for +comparison with the candidate. This is signature/record verification, not +contribution algebra. Preserve the running proof-tool executable-identity gate +separately from the coordinator's claimed executable identity. +Retain frozen R1CS parsing and native/Cardano verifying-key export coherence. +V4 also verifies the published example proof and report claims with that key; +this is not contribution replay. Do not silently add that stronger proof gate +to older released formats or require proving-key regeneration. + +Require the bundle pair's canonical `operational/evidence-bundle.json` and `.sig` +names and confined reads. Verify its signature, read its signed assembly time, +then rederive the unchanged bundle from this +exact review checkpoint using that same time, and require byte equality plus +the coordinator signature and original bundle verification. Authenticate all +checkpointed audits in deterministic order and enforce the full signed minimum, +then require a nonzero UTC release time strictly after candidate finalization, +bundle assembly and the latest audit (when audits are enabled). Return a +deterministic unsigned local result containing the exact review checkpoint pair, +final-candidate checkpoint pair, candidate inventory, bundle pair, sorted audit +pairs, replay method/executable digest and release time. It is not authorization +by itself: do not sign, publish or claim freshness from this operation. + +This trusts the coordinator's signed claim that full contribution replay was +performed. It verifies every final candidate and required operational evidence +file it uses, but does not independently repeat contribution mathematics or +claim to rehash every unrelated historical payload. Optional independent replay +remains a separate action. Existing V3 signing continues to require that replay. + +The later release packager must bind this review checkpoint and the exact +final-candidate checkpoint in FinalTranscript V3. Recheck the same inputs before +signing and conditionally append against that predecessor: a newer checkpoint +requires new review, even if an older bundle still has a valid signature. +Packaging layout and closed publication inventory remain a separate reviewed +implementation step; this read-only API does not activate V4 releases. + +### Reviewed release packaging (implementation in progress) + +Keep the application key-bundle layout and manifest format unchanged: one copy +of each native key at the release root. FinalTranscript V3 binds the exact V4 +review result, including its review and final-candidate checkpoints, operational +bundle, audit pairs, coordinator replay claim and proposed release time. +Inline the review in the transcript; no separate review file is necessary. +The release signature authenticates that transcript through the existing +manifest hash. Older transcript formats must reject these new fields. + +Preserve original logical paths for checkpoint history and operational/audit +records. Do not copy every historical contribution payload into the key bundle: +this package verifies the coordinator's replay claim and required records, not +an independent contribution replay. A full replay archive is a separate product. +Derive a sorted, unique `RequiredArtifacts` set from verified history and +evidence: all checkpoint ancestry pairs, the definition pair, candidate files, +bundle pair and verified referenced artifacts, audit pairs, and lifecycle +chain/close/beacon/seal pairs with their raw beacon responses. Include governance +statements through the verified bundle. Conflicting digests at one name fail. +These are source-review dependencies, not the subsequently generated manifest, +signature, transcript or checksum file. Never publish by recursively copying +the input workspace. Test review against a copy of only this set. + +The candidate is committed under `final/candidate/`, whereas application keys +remain at the release root. Use a fixed, V4-only mapping of the closed +candidate filename set to root files; do not use symlinks, arbitrary aliases or +caller-controlled path rewrites. Reject conflicting physical destinations and +extra files. Every V4 package read, including audit candidate reads and candidate +prehashes, uses the same rule. The ordinary staging-root mode remains unchanged. +Make an independent checked copy into private staging; no hardlinks, destructive +moves, or copy-on-write dependency. One key copy within the release does not mean +removing the source copy. Recheck copied bytes in staging before +signing, then self-verify the exact signed package before atomically publishing +the local directory. Local package creation is not cloud/public publication. + +The final-release checkpoint must use the exact reviewed predecessor; a changed +backend head requires another review. Keep production approval/NO-GO and public +publication separate, including NO-GO when finalization fails. This layout is +independently reviewed; it does not yet enable V4 release signing. In V4 the +retained manifest-v1 `published_at` value means package-finalization time, not +proof of upload or public availability. + +The local signing/verifying library is implemented with this fixed layout and +inline transcript binding. Its output directory must be disjoint from the +source tree, including after resolving parent-directory symlinks. Review found +that exact retries could otherwise accept byte-identical destinations containing +external hardlinks: reverify the actual destination after local publication, +including its copied definition pair. A failure then is explicitly committed; +retain the destination rather than deleting it or reporting success. Exact +signing retries currently require retained source-review dependencies; standalone +verification can use the package itself. This does not implement backend +checkpoint append, production approval, or public publication. +Linux ceremony/CLI suites and vet passed the package implementation. A final +focused Linux run and vet passed after the output-separation guard, including +the real tiny signing/retry path and maximum-size transcript tests. These are +single-process historical-beacon fixtures, not live storage or independent roles. + +The first dependency-only snapshot test found an implementation mismatch: +the shared operational verifier hashed and returned every historical +genesis/contribution payload as a dependency. The reviewed V4-only correction +omits those historical bytes while preserving the exact signed chain, cleanup, +verification, custody, observer and beacon records. This is schema-dispatched, +not a caller-selectable skip flag. Final review separately requires the exact +coordinator replay claim; a standalone bundle check does not prove that replay. +Released V1–V3 payload checks remain unchanged. The dependency-only test uses +the copied definition/signature too, retaining only the independent public-key +trust anchor externally; missing signed records and the signed beacon's exact raw +drand response fail. +The Linux ceremony/CLI suites and vet pass this correction, including explicit +V3 missing-payload rejection and all three V4 real tiny fixture configurations. +Before inlining the dependency inventory into FinalTranscript V3, test its +maximum canonical size against the bounded transcript reader; do not silently +raise all JSON limits or permit a late-signing size failure. + +### Record the signed private package + +Commit five small references under the fixed `final/release/` location: manifest +and signature as the transition pair, plus transcript, exact release checksums, +and bundled public key as evidence. The transcript itself can be large; only its +reference is small. Do not copy the full packaged checkpoint history into the +outer checkpoint's accepted-artifact inventory again. + +Before authoring this edge, verify the complete closed package and require its +embedded review checkpoint to equal the exact predecessor pair, including the +signature. Its final-candidate checkpoint must equal authenticated history. +The release signer binds manifest → transcript → reviewed dependencies, while +the coordinator checkpoint additionally binds the exact checksum file. Those +checks cover the full package, not merely its five bootstrap files. + +Keep names in two explicit scopes: package-relative artifact names and the fixed +ceremony-relative package prefix. Return a typed inventory with that prefix and +the complete package-relative references; one locator constructs download paths. +Do not double-prefix names or describe the five initial files as the whole +download. GO/NO-GO and public publication remain separate, later actions. + +Review identified two maximum-size constraints. The V4 checksum list also needs +a dedicated derived bound: `(maximum review dependencies + 5) × (64 hash bytes ++ 2 spaces + 512 filename bytes + newline)`, without widening legacy readers. +Test its exact maximum and rejection above the bound. The outer V4 checkpoint +must also reserve capacity for the five final references; do not let a legal +pre-release inventory become impossible to finish. Keep legacy limits unchanged. +Allow the original accepted-artifact limit plus five only for the V4 +`final-release-recorded` transition, not merely because a progress field is set. +Test an exactly-full predecessor, pre-release overflow, and an extra sixth +final reference. Package-relative names retain their 512-byte bound; the +transport validates the separately constructed prefix-plus-name object key. + +The dedicated checksum bound is implemented in proof-tool `e8a1c95`. The exact +maximum-size parser/reader test and over-limit negative pass; existing readers +retain their original limit. Full Linux ceremony and CLI suites plus vet pass +(106.159 s and 46.561 s for the suites). Those results verify the checksum +change; the later final-release edge is tracked separately below. + +The final-release library edge is now implemented. Its full verifier returns +all package files through read-only inventory accessors; the fixed prefix is +separate. Review caught that exported mutable inventory fields could let a +caller replace the verifier's membership, so those fields are private and slice +accessors return copies. Structural stored-state inspection deliberately does +not imply full package verification. The real tiny fixture exercises both +boundaries: a changed final key leaves signed-state inspection valid but makes +the full package check fail. Normal CLI/backend wiring remains pending. + +The sequence bound has headroom: every non-delivery edge needs at least a fresh +signed pair (governance also rejects already-committed records), while a delivery +slot can be allocated once and become terminal once. Even counting these +separately and adding the final edge remains below the sequence limit. A unit +test ties that inequality to the protocol constants; it is not a full journey +model-checking result. + +Validation: the Linux ceremony/CLI suites and vet passed this edge (109.342 s +and 46.008 s). After the read-only inventory fix, all three real tiny V4 fixture +configurations, boundary units and vet passed again (85.349 s). These use known +test keys and historical beacon responses, not independent operators or live +storage. The package and checkpoint remain private local test outputs. + +Tests: changed final files, omitted/extra candidate refs, wrong replay binary, +missing/bad bundle signature, old bundle after a new committed incident or +enrollment, partial audit quorum, invalid release time, terminated state, and +successful review with no replay/circuit input. Preserve V1–V3 regression tests. + +Apply the same mechanism to Phase 2, enrollments and enabled observer/auditor +evidence. Preserve the signed optional-role minima and configurable future +beacon lead for both rehearsal and production. + +Reuse FinalCandidateRecorded as the coordinator replay claim. Freeze the exact +candidate and required evidence for the release signer. Add a release verifier +that checks signatures, hashes, legal transitions and policy without running +the contribution replay. Keep the explicit NO-GO path when replay fails. +The release-signer role remains; removing it is a separate decision. + +For the Mac rehearsal's archive verifier, run the Linux verifier inside the +pinned Docker image. Do not execute a Linux binary directly on macOS. + +## 5. Verify and publish the compatible pairing + +### Reviewed production-decision integration (next) + +Use a separate Decision V3 and draft V3 for Definition V4. Preserve released +decision structs, hash domains, limits and V1–V3 definition dispatch. Review +confirmed the old enumerated release list, 16 MiB transcript reader and old +checksum layout cannot represent every valid new package. + +- Bind the exact final-release checkpoint pair and ceremony under a new release + ID domain. Include the candidate ID for readable review and verify it against + the package. Do not enumerate package files again or accept another manifest. +- V4 preparation and evidence verification call + `VerifyFinalReleaseCheckpointV4` against authenticated trust and the exact + artifact root. This checks the complete private package, coordinator replay + claim, mandatory release signature and enabled assurance evidence without + repeating contribution mathematics. +- Derive the ceremony-auditor signer list from the package; if the decision + includes that list for readability, require exact equality. Do not duplicate + the operational bundle or audit files as a second evidence source. +- GO needs the coordinator, release signer and every package-bound ceremony + auditor. With audits disabled, only the first two sign; reject extra signers. + Witnesses and mirrors do not sign this decision. +- Preserve the existing external production gates and their explicit evidence: + source release, exact K=21 rehearsal, deployment plan, formal checklist, + external security reviews and operational claims. Package verification + supplies package-derived gates without redundant hand-entered URL arrays. + Disabled assurance gates remain exactly `NOT_REQUIRED`. +- Never include private access URLs or credentials. The compact package binding + uses logical names and digests; trusted delivery maps those to storage. + Require the decision time to be no earlier than package finalization. +- Before a final package exists, NO-GO uses the authenticated abort path; do not + manufacture empty release evidence. A post-package NO-GO binds the same exact + checkpoint but cannot authorize publication. Public publication is separate + and requires verified GO. + +Tests must reject cross-version dispatch, wrong checkpoint/candidate, corrupt +package, missing/extra auditor or other signatures, altered assurance policy, +credential-bearing evidence URLs and a decision predating the release. Preserve +all existing decision tests. New APIs must not accept a contribution-replay +callback or circuit input; independent replay remains a separate optional task. + +Further review: released source evidence hard-requires an OpenPGP tag, which +does not fit the already agreed protected-main CI release model. Decision V3 +instead uses `source_commit` and a plain `verification_report` artifact, with a +separate V3-only `source-release` gate bound to that same report. The existing +`signed-release` gate means the ceremony package, not its source provenance. +All new external evidence references are logical names/digests, not URLs. +Proof-tool bounds and hashes the report and checks the source commit; it does +not contact GitHub or interpret a delivery-tool-specific report schema. Relay +must validate and display the report's exact commit, repository, workflow and +result before approval, never auto-pass it merely because the file exists. +Keep the reviewed source report at `decision/evidence/source-release.json`; +other decision evidence lives under `decision/evidence/` with collision checks, +separate from `final/release/`. Preserve the old source-evidence format unchanged. + +Decision V3 core APIs are implemented. Review tightened production-mode +dispatch, fixed package-derived gates to PASS, bound structured gate entries to +their exact report fields, and rejected mixing a package verified after a +trust-file replacement with the initially authenticated definition. GO requires +the exact coordinator/release-signer/package-auditor signature set. Early NO-GO +still uses abort; post-package NO-GO must identify an external/review failure. +Tests cover canonical/version boundaries, source/policy/candidate/time binding, +real known-key Ed25519 thresholds and external signoffs, and fail-closed missing +packages. A real K21 package through the full public approval APIs, CLI wiring +and public publication are still pending; do not claim production readiness. +The Linux ceremony/CLI regression suites and vet passed (111.090 s and +48.328 s). After the final mixed-definition fix, the focused new/legacy decision +tests and vet passed in Linux (1.638 s); native decision units also pass. These +are record/signature/evidence tests, not a production GO claim. + +- Model ordinary delay, missing files, duplicate actions, crashes and two local + writers; distinguish retired delivery from rejected candidate. +- Test wrong signatures, changed payloads, wrong turn/head/identity, rejected + candidate redelivery and exact acceptance after restart. +- Run a complete tiny ceremony through the actual role menus with optional + roles disabled, plus an enabled-role run exercising evidence requirements. +- Test S3 and R2 partial uploads, renewal, conditional writes and timeout recovery. +- Run one independent adversarial review against this agreed trust model. +- Merge/release proof-tool, verify both architecture assets and provenance, + pin them in Relay, then retest the released pairing before merging Relay. +- Update the versioned Tessera contract and compatibility fixtures, test its + integration, then release Relay and provision/deploy the exact pairing. + +Passing the existing PR CI demonstrates the earlier implementation only. +The new journey is complete when the tests above pass with the revised schemas +and normal CLI, and users can proceed without manual file-placement knowledge. diff --git a/docs/maintainer/trusted-storage-v4-design.md b/docs/maintainer/trusted-storage-v4-design.md new file mode 100644 index 0000000..de48f09 --- /dev/null +++ b/docs/maintainer/trusted-storage-v4-design.md @@ -0,0 +1,293 @@ +# Trusted-storage ceremony V4 + +Status: proposed replacement for the unreleased Relay V4 journey, September 16, +2026. This design does not change released Definitions V1–V3 or their +checkpoints. + +## Why V4 is being simplified + +The earlier storage-first protocol retained manual-custody evidence from a model +where the coordinator or delivery service might lie. Our chosen operating model +is narrower: + +- the coordinator follows the protocol and performs the required full + mathematical replay; +- the configured storage service returns committed state and immutable objects + correctly; +- role operators follow the ceremony instructions and protect their own keys; +- ordinary mistakes, stale local state, interrupted processes, corrupt files and + concurrent local invocations remain in scope. + +Under that model, a participant signature saying “I received these bytes” adds +no cryptographic protection. The contribution command must authenticate and +verify those bytes immediately before it samples secret randomness anyway. +Input and return custody receipts therefore add four signed exchanges without +changing the mathematical result. + +V4 removes those exchanges. It keeps the checks that prevent ordinary mistakes +and keeps coordinator replay mandatory. + +## Compatibility boundary + +Released formats are immutable. + +| Format | Meaning | +| --- | --- | +| Definitions V1–V3 | Existing file/manual-storage ceremonies; unchanged | +| Definition V4 + Checkpoint V4 | New trusted-coordinator/trusted-storage workflow described here; unreleased | + +V4 uses explicit identifiers. A tool must never infer V4 from missing legacy +fields or fall back to a legacy verifier after V4 authentication fails. The +earlier receipt-based V4 draft was never released and has no compatibility +promise; replace it rather than carrying its custody records forward. + +## The complete participant turn + +```text +Coordinator commits whose turn it is and allocates one upload attempt + | + v +Participant synchronizes the signed state and downloads its exact inputs + | + v +Proof-tool rechecks state, assignment, chain, head and files inside the +isolated contributor immediately before generating randomness + | + v +Participant contributes, removes the container, confirms cleanup and uploads +the five fixed public candidate files; the manifest is uploaded last + | + v +Coordinator checks signatures, scope, cleanup claim and contribution math + | + +---------+---------+ + | | + v v + commit exact acceptance commit exact rejection + | | + v v + next turn/phase optional new attempt +``` + +There is no input handoff, participant input receipt, return handoff or +coordinator return receipt in V4. + +## Signed allocation + +The coordinator's next signed checkpoint is the participant's authorization to +compute. Its transition binds: + +- ceremony ID and Definition V4 reference; +- phase and one-based contribution index; +- assigned participant identity, signing key and schedule position; +- exact current accepted-head ID; +- exact signed chain pair and head-payload reference; +- for Phase 2, the exact authenticated Phase 1 seal and required dependency + boundary; +- a fresh candidate-attempt ID; +- the exact allocation checkpoint pair, previous checkpoint pair and next + sequence number; +- coordinator allocation time. + +Bucket names, object keys, URLs and cloud credentials are Relay concerns. They +do not appear in proof-tool records. Relay deterministically maps the signed +attempt ID to one private upload prefix supplied by the trusted storage setup. + +Only one candidate attempt for a turn may be active. Allocating another first +requires a signed retirement or rejection of the previous attempt. + +## Participant computation + +Relay downloads content-addressed public inputs into a fresh private local +directory. Download-time hashes detect transfer corruption but do not authorize +computation. + +Before the contributor container samples randomness, the approved proof-tool +must verify all of the following in one invocation: + +1. the independently trusted coordinator key authenticates Definition V4; +2. the exact Checkpoint V4 pair is signed and has valid ancestry; +3. the checkpoint has one active candidate allocation for this participant; +4. the allocation matches the signed schedule, phase, index and current head; +5. the exact chain, chain signature, head payload and Phase 2 dependencies match + the checkpoint references, including both signed digests and sizes; +6. the local signing key belongs to the assigned participant; +7. the approved runtime and platform match the authenticated definition. + +Verification and randomness generation happen in the same network-disabled +process with no user pause. Relay supplies a private, read-only snapshot whose +files cannot be replaced after verification; proof-tool does not reopen mutable +storage paths. The command reports its exact verification depth. Under this +trust model it authenticates the signed state and complete input inventory but +does not replay every earlier contribution before sampling; the coordinator +still performs the mandatory full replay before release. Phase 2 retains the +existing closed-Phase-1 checks required to authenticate its starting state. + +The isolated contribution and cleanup behavior remains unchanged: no network, +read-only root, narrow mounts, disabled core dumps/logging, bounded temporary +memory, explicit container identity, forced removal and verified absence. + +The public candidate contains exactly: + +- `attestation.json`; +- `attestation.sig`; +- `contribution.bin`; +- `erasure.json`; +- `erasure.sig`. + +The cleanup record is an authenticated honest-operator claim, not proof that no +copy survives on the host. + +## Upload and acceptance + +Relay uploads immutable candidate files first and a bounded manifest last. A +missing manifest means the submission is incomplete. Repeating the same upload +may only confirm identical bytes; it must never replace an existing object with +different bytes. + +The coordinator accepts only the active attempt and fixed five-file inventory. +Proof-tool then: + +- verifies participant signatures and exact turn scope; +- verifies the cleanup record follows contribution creation; +- replays the contribution mathematics from the exact accepted predecessor; +- constructs the exact next signed chain; +- derives a canonical result ID over all five candidate files; +- binds the verification result, accepted chain and result ID into the next + checkpoint. + +Acceptance advances the ceremony. Upload completion alone does not. + +If bytes are mathematically or structurally invalid, the coordinator commits a +semantic rejection bound to their complete result ID. Those exact bytes can +never later be accepted. Producing new candidate bytes requires an explicit +operator-approved fresh computation and is never an automatic retry. + +Transport retry stays within the same signed candidate attempt. Relay can +renew its grant and resume an interrupted immutable upload after comparing all +already-stored bytes. That does not create new contribution randomness. + +If the coordinator retires an attempt, its replacement is a new signed +allocation. Proof-tool requires that allocation to predate the contribution, +so a candidate made for the retired allocation cannot be moved to the +replacement. Relay preserves the old files for investigation; a replacement +requires an explicit fresh contribution and cleanup statement. Attempt history +is append-only and bounded, and one turn has at most one active attempt. + +## Recovery rules + +- Synchronization and downloads are read-only and may be repeated into fresh + directories. +- Relay records the exact contribution operation before creating the container. + An uncertain operation is inspected; it is never automatically repeated. +- Relay records the exact upload attempt and manifest before network mutation. + Restart compares remote bytes before continuing. +- Coordinator verification durably records the exact result ID and current + root. Immediately before acceptance, Relay re-reads the current root, confirms + that the allocation remains active, and conditionally publishes a checkpoint + descending from that root. On a race it revalidates and rebuilds; it never + merely re-signs stale bytes. Restart distinguishes verified but not signed, + signed but not published, and published states. +- A newer valid backend checkpoint wins over a stale local suggestion. Local + pending work is still shown and reconciled; it is not silently discarded. +- An allocation survives unrelated descendant checkpoints only while its + participant, parent head and scope remain active and no terminal state or + accepted result supersedes it. +- A running legacy operation always resumes with its original runtime and rules. + +## Optional assurance roles + +Witnesses, mirrors, ceremony auditors and external security audits remain +independently configurable in the signed policy. Disabled controls create no +assignments, prompts, evidence requirements or empty placeholders. Enabled +controls retain their existing signed evidence semantics. + +Future drand verification remains mandatory even when public witnesses are +disabled. The beacon is part of the mathematical phase transition, while a +witness is an optional independent observation of publication timing. + +## Final verification and release + +The coordinator must replay the complete accepted transcript and bind that +successful verification to the exact final files. This is not a new requirement +and is not optional. + +The release signer remains required. The signer verifies the exact candidate, +definition, checkpoint ancestry, coordinator replay statement, signatures, +policy and required enabled evidence. Repeating the complete contribution +mathematics is optional for the signer only when the signed V4 policy selects +coordinator-replay review. A replay-required policy remains available; this is +never inferred from a CLI flag or missing field. + +The V4 operational bundle contains accepted chains, participant contribution and +cleanup records, coordinator replay evidence, each signed phase beacon with its +one verified raw drand response, and any enabled observer/audit evidence. A +second drand endpoint is only an availability fallback. It contains no +input/return custody records. + +V4 uses explicit V4 bundle, final-transcript, decision and release schemas. +Removed custody fields are absent, not encoded as empty legacy fields. V1–V3 +validators remain schema-dispatched and unchanged. Evidence for a disabled +optional role is rejected; enabled roles must meet the minima signed in the +definition. + +Only the exact private review package approved by the release signer may be +copied into public storage. + +## Role guidance derived from backend state + +On every startup Relay authenticates the newest checkpoint returned by the +configured storage service, then combines it with retained local operation +state. Guidance is deterministic: + +| Authenticated state | Participant instruction | Coordinator instruction | +| --- | --- | --- | +| no active allocation | wait | allocate the next scheduled turn | +| active allocation for this participant | verify inputs and contribute | wait for the manifest | +| complete candidate manifest | wait for acceptance | verify and accept or reject | +| accepted result ID matches local candidate | turn complete | proceed to next turn or closure | +| rejected result ID matches local candidate | preserve diagnostics; do not retry those bytes | optionally allocate a fresh attempt | +| local operation outcome uncertain | inspect exact retained operation | inspect exact retained operation | + +No notification age changes these rules. Notifications only prompt a refresh; +the signed backend state determines the action. + +## Required invariants + +1. A contribution cannot start without an active signed allocation for its exact + participant, phase, index and parent head. +2. Proof-tool rechecks allocation and every input in the contributor invocation, + before randomness exists. +3. At most one active candidate attempt exists for a turn. +4. An interrupted upload resumes only under its original allocation. Retiring + that allocation permanently prevents its candidate from being accepted; + replacement bytes require an explicit fresh-computation decision. +5. Upload success never means acceptance. +6. Acceptance always includes full mathematical verification and advances from + the exact signed predecessor. +7. Rejected result IDs cannot later be accepted. +8. Coordinator full replay remains mandatory before final release. +9. Release-signer replay is optional, but the signer role and exact approval + signature remain mandatory. +10. V1–V3 verification behavior remains byte-for-byte compatible. +11. Contribution creation follows allocation time; cleanup follows contribution; + acceptance follows cleanup. Unsigned manifest timestamps do not establish + ceremony chronology. +12. A manifest is only a discovery marker. The coordinator rehashes and + authenticates every referenced file before mathematical verification. + +## Implementation order + +1. Replace the unreleased receipt-based Definition/Checkpoint V4 draft while + leaving released V1–V3 behavior unchanged. +2. Implement and exhaustively test the short allocation/acceptance state machine + in proof-tool. +3. Add one proof-tool command that verifies allocation and inputs and then starts + the existing isolated contribution. +4. Add V4 bundle/review/decision/release schemas without custody fields. +5. Route Relay's normal V4 participant and coordinator guides through the new + states and crash journal. +6. Run complete tiny rehearsals, live S3 and R2 journeys, adversarial review and + production-sized benchmarks. +7. Release proof-tool, pin and release Relay, then provision the compatible + Tessera release without altering existing frozen ceremonies. diff --git a/docs/roles/release-signer.md b/docs/roles/release-signer.md index 3e26984..7db6c2a 100644 --- a/docs/roles/release-signer.md +++ b/docs/roles/release-signer.md @@ -25,7 +25,7 @@ or an online upload profile onto the signing machine. ## Follow the workflow -- Review the exact candidate, both phases, auditor identities, beacon evidence, +- Review the exact candidate, both phases, any enabled auditor identities, the signed beacon records, witness/mirror evidence, incidents, and contribution-bound cleanup statements. - Supply the evidence paths when prompted and require verification to pass. - Authorize signing only the exact verified release manifest. diff --git a/docs/tessera-setup-v3.md b/docs/tessera-setup-v3.md new file mode 100644 index 0000000..d4c8f58 --- /dev/null +++ b/docs/tessera-setup-v3.md @@ -0,0 +1,42 @@ +# Tessera setup v3 + +Setup v3 is the website/CLI contract for ceremonies that use configurable +assurance roles and a configurable wait before the future beacon round. It is +an additive contract: Relay continues to accept and verify setup v2 files. + +The signed setup records: + +- the coordinator, final signer, participants and any enabled auditors; +- the participant order and minimum for both phases; +- the exact positive closure-to-beacon wait selected for this ceremony; +- how many witness, mirror, ceremony-audit and external-audit results are +required, where zero explicitly disables that requirement; +- the storage destinations and exact approved Relay/proof-tool release. + +Relay imports the setup, verifies the release manifest, and reproduces the +same values in the signed ceremony definition. Before exporting the completed +setup, Relay verifies that the definition has the same circuit, beacon and +assurance policy. Changing any of those values requires a new website setup. + +Witness and mirror identities are deliberately not part of the authoritative +setup plan. Only their required counts are signed. Tessera may keep invitations +as website-local coordination data; after initialization, the coordinator +assigns numbers and verifies each observer's signed enrollment. + +New releases publish and attest `ceremony-software-manifest-v3.json`. Tessera +uses v3 for new ceremonies and retains v2 verification with the older release +selected by an existing v2 setup. A new release does not advertise a v2 +manifest because its proof-tool emits definition v3. + +The defaults remain 180 seconds for rehearsals and 86,400 seconds for +production. A shorter production wait is allowed only after an explicit CLI +warning and is recorded in the signed definition. Zero is never allowed. + +Disabling an assurance role removes its required enrollment and evidence; it +does not weaken signature, contribution, beacon, final-file or release +verification. The CLI must show the lost assurance when a requirement is zero. + +`--trusted-manifest` is an administrator-controlled trust input. Provision it +from the independently attested release catalogue; never accept a manifest +supplied beside an uploaded setup file. Relay may execute the selected image +before it can inspect the ceremony artifacts inside it. diff --git a/docs/tessera.md b/docs/tessera.md index e62ae53..1184743 100644 --- a/docs/tessera.md +++ b/docs/tessera.md @@ -1,7 +1,8 @@ # Prepare a setup for Tessera This chapter describes the legacy v1 roster flow. New website drafts use the -[shared v2 setup](tessera-setup-v2.md). +[shared setup contract](tessera-setup-v3.md). Existing ceremonies using the +[v2 contract](tessera-setup-v2.md) remain supported. Use a CLI release containing `tessera export-setup` and the matching installed role images. Older releases do not support this workflow. Run diff --git a/go.mod b/go.mod index cb0ff42..477a6f5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,10 @@ go 1.26.6 require ( github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + golang.org/x/crypto v0.41.0 ) -require golang.org/x/text v0.14.0 // indirect +require ( + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum index 60454b4..6c5c2ea 100644 --- a/go.sum +++ b/go.sum @@ -4,5 +4,9 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= diff --git a/internal/access/storage_first_grant.go b/internal/access/storage_first_grant.go new file mode 100644 index 0000000..b8b3295 --- /dev/null +++ b/internal/access/storage_first_grant.go @@ -0,0 +1,175 @@ +package access + +import ( + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +const ( + // GrantSchemaV2 adds an immutable storage-first submission scope. Grant and + // GrantSchema remain the supported v1 format for existing ceremonies. + GrantSchemaV2 = "relay-role-grant-v2" + + SubmissionKindCandidate = "candidate" + SubmissionKindEnrollment = "enrollment" + SubmissionKindRelease = "release" + + maxStorageFirstContributionIndex = 255 + maxStorageFirstGrantLifetime = time.Hour + maxStorageFirstGrantClockSkew = 5 * time.Minute +) + +// StorageFirstGrant is one temporary credential bound to one preallocated +// submission slot. It never grants authority merely because a prefix happens +// to contain similarly named objects. +type StorageFirstGrant struct { + Schema string `json:"schema"` + Provider string `json:"provider"` + CeremonyID string `json:"ceremony_id"` + GrantRequestID string `json:"grant_request_id"` + CheckpointDigest string `json:"checkpoint_digest"` + SubmissionKind string `json:"submission_kind"` + Phase string `json:"phase"` + Index uint8 `json:"index"` + IdentityID string `json:"identity_id"` + AttemptID string `json:"attempt_id"` + Endpoint string `json:"endpoint,omitempty"` + Region string `json:"region,omitempty"` + InboxBucket string `json:"inbox_bucket"` + Prefix string `json:"prefix"` + ManifestKey string `json:"manifest_key"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` + Credentials SessionCredentials `json:"credentials"` +} + +func (g StorageFirstGrant) Validate() error { + if g.Schema != GrantSchemaV2 { + return fmt.Errorf("grant schema %q, want %q", g.Schema, GrantSchemaV2) + } + if g.Provider != "r2" && g.Provider != "aws" { + return errors.New("grant provider must be r2 or aws") + } + if g.Provider == "r2" { + endpoint, err := url.Parse(g.Endpoint) + if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { + return errors.New("R2 grant requires an HTTPS endpoint without credentials, query, or fragment") + } + } else if g.Region == "" || strings.TrimSpace(g.Region) != g.Region { + return errors.New("AWS grant requires a region") + } + if !validHashID(g.CeremonyID) { + return errors.New("grant ceremony_id is not a tagged SHA-256 digest") + } + if !validAttempt(g.GrantRequestID) { + return errors.New("grant_request_id must be 16 bytes of lowercase hexadecimal") + } + if !validHashID(g.CheckpointDigest) { + return errors.New("checkpoint_digest is not a tagged SHA-256 digest") + } + switch g.SubmissionKind { + case SubmissionKindCandidate: + if g.Phase != "phase1" && g.Phase != "phase2" { + return errors.New("candidate grant phase must be phase1 or phase2") + } + if g.Index == 0 || g.Index > maxStorageFirstContributionIndex { + return fmt.Errorf("candidate grant index must be between 1 and %d", maxStorageFirstContributionIndex) + } + case SubmissionKindEnrollment: + if g.Phase != "setup" || g.Index == 0 || g.Index > 20 { + return errors.New("enrollment grant requires setup phase and a role index between 1 and 20") + } + case SubmissionKindRelease: + if g.Phase != "release" || g.Index != 1 { + return errors.New("release grant requires release phase and index 1") + } + default: + return errors.New("submission_kind must be candidate, enrollment or release") + } + if !validComponent(g.IdentityID) || !validComponent(g.InboxBucket) { + return errors.New("grant identity or inbox bucket is invalid") + } + if !validAttempt(g.AttemptID) { + return errors.New("attempt_id must be 16 bytes of lowercase hexadecimal") + } + if !validSubmissionPrefix(g.Prefix) { + return errors.New("grant prefix must be a safe non-root relative prefix ending in slash") + } + if g.ManifestKey != g.Prefix+"manifest.json" || !validRelativeName(g.ManifestKey) { + return errors.New("manifest_key must be exactly manifest.json within the granted prefix") + } + issued, err := parseCanonicalGrantTime("issued_at", g.IssuedAt) + if err != nil { + return err + } + expires, err := parseCanonicalGrantTime("expires_at", g.ExpiresAt) + if err != nil || !expires.After(issued) { + return errors.New("expires_at must be canonical RFC3339 UTC and after issued_at") + } + if expires.Sub(issued) > maxStorageFirstGrantLifetime { + return fmt.Errorf("storage-first upload credentials may last at most %s", maxStorageFirstGrantLifetime) + } + return g.Credentials.Validate() +} + +func (g StorageFirstGrant) CheckUnexpired(now time.Time) error { + if err := g.Validate(); err != nil { + return err + } + issued, _ := time.Parse(time.RFC3339, g.IssuedAt) + expires, _ := time.Parse(time.RFC3339, g.ExpiresAt) + if issued.After(now.UTC().Add(maxStorageFirstGrantClockSkew)) { + return fmt.Errorf("upload credentials are future-dated beyond the allowed %s clock skew", maxStorageFirstGrantClockSkew) + } + if !expires.After(now.UTC()) { + return fmt.Errorf("upload credentials expired at %s", g.ExpiresAt) + } + return nil +} + +// ValidateRenewal permits fresh credentials only for the identical committed +// submission. A replacement contribution needs a new checkpoint slot instead. +func (g StorageFirstGrant) ValidateRenewal(previous StorageFirstGrant) error { + if err := previous.Validate(); err != nil { + return fmt.Errorf("previous grant: %w", err) + } + if err := g.Validate(); err != nil { + return fmt.Errorf("renewed grant: %w", err) + } + if g.GrantRequestID == previous.GrantRequestID { + return errors.New("renewal requires a new grant_request_id; retrying one request is idempotent, not renewal") + } + if g.Provider != previous.Provider || g.CeremonyID != previous.CeremonyID || + g.CheckpointDigest != previous.CheckpointDigest || g.SubmissionKind != previous.SubmissionKind || + g.Phase != previous.Phase || g.Index != previous.Index || g.IdentityID != previous.IdentityID || + g.AttemptID != previous.AttemptID || g.Endpoint != previous.Endpoint || g.Region != previous.Region || + g.InboxBucket != previous.InboxBucket || g.Prefix != previous.Prefix || g.ManifestKey != previous.ManifestKey { + return errors.New("renewal changed the committed submission scope") + } + previousIssued, _ := time.Parse(time.RFC3339, previous.IssuedAt) + issued, _ := time.Parse(time.RFC3339, g.IssuedAt) + previousExpires, _ := time.Parse(time.RFC3339, previous.ExpiresAt) + expires, _ := time.Parse(time.RFC3339, g.ExpiresAt) + if issued.Before(previousIssued) || !expires.After(previousExpires) { + return errors.New("renewal must not move issuance backward and must extend credential expiry") + } + return nil +} + +func validSubmissionPrefix(value string) bool { + if len(value) < 2 || len(value) > 512 || !strings.HasSuffix(value, "/") { + return false + } + return validRelativeName(strings.TrimSuffix(value, "/")) +} + +func parseCanonicalGrantTime(field, value string) (time.Time, error) { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil || value != parsed.UTC().Format(time.RFC3339) { + return time.Time{}, fmt.Errorf("%s must be canonical RFC3339 UTC", field) + } + return parsed, nil +} diff --git a/internal/access/storage_first_grant_test.go b/internal/access/storage_first_grant_test.go new file mode 100644 index 0000000..035e480 --- /dev/null +++ b/internal/access/storage_first_grant_test.go @@ -0,0 +1,150 @@ +package access + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func validStorageFirstGrant() StorageFirstGrant { + attempt := strings.Repeat("b", 32) + prefix := "submissions/" + strings.TrimPrefix(testCeremony, "sha256:") + "/" + attempt + "/" + return StorageFirstGrant{ + Schema: GrantSchemaV2, Provider: "r2", CeremonyID: testCeremony, + GrantRequestID: strings.Repeat("a", 32), CheckpointDigest: "sha256:" + strings.Repeat("c", 64), + SubmissionKind: SubmissionKindCandidate, Phase: "phase1", Index: 1, + IdentityID: "participant-03", AttemptID: attempt, + Endpoint: "https://account.r2.cloudflarestorage.com", Region: "auto", InboxBucket: "inbox", + Prefix: prefix, ManifestKey: prefix + "manifest.json", + IssuedAt: "2026-09-15T01:00:00Z", ExpiresAt: "2026-09-15T02:00:00Z", + Credentials: SessionCredentials{AccessKeyID: "temporary-id", SecretAccessKey: "temporary-secret", SessionToken: "temporary-token"}, + } +} + +func TestStorageFirstGrantStrictBinding(t *testing.T) { + valid := validStorageFirstGrant() + if err := valid.Validate(); err != nil { + t.Fatalf("valid grant: %v", err) + } + tests := []struct { + name string + mutate func(*StorageFirstGrant) + }{ + {"request", func(g *StorageFirstGrant) { g.GrantRequestID = "request" }}, + {"checkpoint", func(g *StorageFirstGrant) { g.CheckpointDigest = strings.Repeat("c", 64) }}, + {"kind", func(g *StorageFirstGrant) { g.SubmissionKind = "release" }}, + {"phase", func(g *StorageFirstGrant) { g.Phase = "phase3" }}, + {"index", func(g *StorageFirstGrant) { g.Index = 0 }}, + {"identity", func(g *StorageFirstGrant) { g.IdentityID = "../other" }}, + {"attempt", func(g *StorageFirstGrant) { g.AttemptID = strings.Repeat("A", 32) }}, + {"prefix traversal", func(g *StorageFirstGrant) { g.Prefix = "submissions/../other/" }}, + {"manifest outside prefix", func(g *StorageFirstGrant) { g.ManifestKey = "other/manifest.json" }}, + {"noncanonical time", func(g *StorageFirstGrant) { g.IssuedAt = "2026-09-15T10:00:00+09:00" }}, + {"expiry", func(g *StorageFirstGrant) { g.ExpiresAt = g.IssuedAt }}, + {"credentials", func(g *StorageFirstGrant) { g.Credentials.SessionToken = "" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + changed := valid + test.mutate(&changed) + if err := changed.Validate(); err == nil { + t.Fatal("changed grant accepted") + } + }) + } +} + +func TestStorageFirstGrantSupportsBothCeremonyPhases(t *testing.T) { + for _, phase := range []string{"phase1", "phase2"} { + grant := validStorageFirstGrant() + grant.Phase = phase + if err := grant.Validate(); err != nil { + t.Fatalf("%s: %v", phase, err) + } + } +} + +func TestStorageFirstGrantSupportsEnrollmentScope(t *testing.T) { + grant := validStorageFirstGrant() + grant.SubmissionKind = SubmissionKindEnrollment + grant.Phase = "setup" + grant.Index = 2 + if err := grant.Validate(); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*StorageFirstGrant){ + func(g *StorageFirstGrant) { g.Phase = "phase1" }, + func(g *StorageFirstGrant) { g.Index = 21 }, + } { + changed := grant + mutate(&changed) + if err := changed.Validate(); err == nil { + t.Fatal("invalid enrollment scope accepted") + } + } +} + +func TestStorageFirstGrantRenewalPreservesAttemptAndScope(t *testing.T) { + previous := validStorageFirstGrant() + renewed := previous + renewed.GrantRequestID = strings.Repeat("d", 32) + renewed.IssuedAt = "2026-09-15T01:30:00Z" + renewed.ExpiresAt = "2026-09-15T02:30:00Z" + renewed.Credentials = SessionCredentials{AccessKeyID: "renewed-id", SecretAccessKey: "renewed-secret", SessionToken: "renewed-token"} + if err := renewed.ValidateRenewal(previous); err != nil { + t.Fatalf("same-attempt renewal: %v", err) + } + + for _, mutate := range []func(*StorageFirstGrant){ + func(g *StorageFirstGrant) { g.AttemptID = strings.Repeat("e", 32) }, + func(g *StorageFirstGrant) { + g.Prefix = "submissions/other/" + g.ManifestKey = g.Prefix + "manifest.json" + }, + func(g *StorageFirstGrant) { g.CheckpointDigest = "sha256:" + strings.Repeat("f", 64) }, + func(g *StorageFirstGrant) { g.IdentityID = "participant-04" }, + func(g *StorageFirstGrant) { g.GrantRequestID = previous.GrantRequestID }, + func(g *StorageFirstGrant) { g.ExpiresAt = previous.ExpiresAt }, + } { + changed := renewed + mutate(&changed) + if err := changed.ValidateRenewal(previous); err == nil { + t.Fatal("scope-changing or non-extending renewal accepted") + } + } +} + +func TestStorageFirstGrantStrictDecodeAndExpiry(t *testing.T) { + grant := validStorageFirstGrant() + raw, err := json.Marshal(grant) + if err != nil { + t.Fatal(err) + } + decoded, err := Decode(raw, StorageFirstGrant.Validate) + if err != nil || decoded.AttemptID != grant.AttemptID { + t.Fatalf("decode: %#v, %v", decoded, err) + } + withUnknown := append(raw[:len(raw)-1], []byte(`,"unexpected":true}`)...) + if _, err := Decode(withUnknown, StorageFirstGrant.Validate); err == nil { + t.Fatal("unknown field accepted") + } + if err := grant.CheckUnexpired(time.Date(2026, 9, 15, 1, 59, 59, 0, time.UTC)); err != nil { + t.Fatalf("unexpired grant rejected: %v", err) + } + if err := grant.CheckUnexpired(time.Date(2026, 9, 15, 2, 0, 0, 0, time.UTC)); err == nil { + t.Fatal("expired grant accepted") + } + future := grant + future.IssuedAt = "2036-09-15T01:00:00Z" + future.ExpiresAt = "2036-09-15T02:00:00Z" + if err := future.CheckUnexpired(time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC)); err == nil { + t.Fatal("far-future grant accepted as a long-lived credential") + } + skew := grant + skew.IssuedAt = "2026-09-15T01:05:00Z" + skew.ExpiresAt = "2026-09-15T02:05:00Z" + if err := skew.CheckUnexpired(time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC)); err != nil { + t.Fatalf("boundary clock skew rejected: %v", err) + } +} diff --git a/internal/state/high_water_lock_unix.go b/internal/state/high_water_lock_unix.go new file mode 100644 index 0000000..ea5a535 --- /dev/null +++ b/internal/state/high_water_lock_unix.go @@ -0,0 +1,52 @@ +//go:build darwin || linux + +package state + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +// lockWorkspaceHighWater serializes the complete read/check/write transaction +// across processes sharing one role workspace. The lock file is stable; it is +// never removed because unlinking a flock file can create two lock domains. +func lockWorkspaceHighWater(path string) (func(), error) { + if info, err := os.Lstat(path); err == nil { + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("workspace high-water lock is not a regular non-symlink file") + } + if info.Mode().Perm()&0o077 != 0 { + return nil, errors.New("workspace high-water lock is accessible by group or other users") + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("inspect workspace high-water lock: %w", err) + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("open workspace high-water lock: %w", err) + } + locked := false + defer func() { + if !locked { + _ = file.Close() + } + }() + opened, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("inspect opened workspace high-water lock: %w", err) + } + current, err := os.Lstat(path) + if err != nil || !current.Mode().IsRegular() || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) { + return nil, errors.New("workspace high-water lock changed while it was opened") + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + return nil, fmt.Errorf("lock workspace high-water: %w", err) + } + locked = true + return func() { + _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + _ = file.Close() + }, nil +} diff --git a/internal/state/v2.go b/internal/state/v2.go new file mode 100644 index 0000000..e81153c --- /dev/null +++ b/internal/state/v2.go @@ -0,0 +1,471 @@ +package state + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "strings" +) + +const ( + // RootSchema identifies the untrusted mutable discovery root. Everything it + // names remains content addressed and must be authenticated by the caller. + RootSchema = "relay-state-root-v2" + + // WorkspaceHighWaterSchema identifies the durable rollback/fork record kept + // in one role workspace. It intentionally is not stored in the ceremony + // bucket whose rollback it detects. + WorkspaceHighWaterSchema = "relay-checkpoint-high-water-v1" +) + +// Root is the only mutable discovery object in the v2 state layout. Its fields +// are hints, not authority: callers must digest-check and authenticate the +// checkpoint and detached signature before using any checkpoint contents. +type Root struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Checkpoint ContentRef `json:"checkpoint"` + CheckpointSignature ContentRef `json:"checkpoint_signature"` +} + +// ContentRef is a fetchable immutable object. Size is authenticated by the +// signed checkpoint/root and lets Relay reject oversized objects before it +// allocates disk or asks proof-tool to parse them. +type ContentRef struct { + Name string `json:"name"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +// RootKey returns the per-ceremony mutable discovery key. Root.Validate still +// has to be called before a value read from this key is trusted as well formed. +func RootKey(ceremonyID string) string { + return "state/" + strings.TrimPrefix(ceremonyID, "sha256:") + "/root.json" +} + +func (r Root) Validate() error { + if r.Schema != RootSchema { + return fmt.Errorf("root schema %q, want %q", r.Schema, RootSchema) + } + if !validDigest(r.CeremonyID) { + return errors.New("root ceremony ID is not a canonical SHA-256 digest") + } + if err := validateContentRef("checkpoint", r.Checkpoint); err != nil { + return err + } + if err := validateContentRef("checkpoint signature", r.CheckpointSignature); err != nil { + return err + } + return nil +} + +func DecodeRoot(raw []byte) (Root, error) { + var root Root + if err := decodeStrict(raw, &root); err != nil { + return Root{}, fmt.Errorf("decode discovery root: %w", err) + } + return root, root.Validate() +} + +func (r Root) Encode() ([]byte, error) { + if err := r.Validate(); err != nil { + return nil, err + } + return json.MarshalIndent(r, "", " ") +} + +// PhaseHeadPosition is the furthest authenticated head retained by this +// workspace. A digest alone cannot distinguish a normal advancing head from a +// same-height fork, hence the explicit accepted index. +type PhaseHeadPosition struct { + Index uint64 `json:"index"` + Digest string `json:"digest"` + Closed bool `json:"closed,omitempty"` +} + +// TerminalPosition retains the absorbing GO/NO-GO decision. Later publication +// checkpoints keep this same terminal decision digest, so they do not look like +// a second decision or erase the first one. +type TerminalPosition struct { + Outcome string `json:"outcome"` + Digest string `json:"digest"` +} + +// CheckpointPosition is the authenticated checkpoint state that may advance a +// workspace high-water mark. PreviousDigest is used to prove the next direct +// step descends from the record already stored locally. +type CheckpointPosition struct { + Sequence uint64 `json:"sequence"` + Digest string `json:"digest"` + PreviousDigest string `json:"previous_digest,omitempty"` + PhaseHeads map[string]PhaseHeadPosition `json:"phase_heads,omitempty"` + Terminal *TerminalPosition `json:"terminal,omitempty"` +} + +func (p CheckpointPosition) Validate() error { + if !validDigest(p.Digest) { + return errors.New("checkpoint digest is not a canonical SHA-256 digest") + } + if p.PreviousDigest != "" && !validDigest(p.PreviousDigest) { + return errors.New("previous checkpoint digest is not a canonical SHA-256 digest") + } + for phase, head := range p.PhaseHeads { + if phase != "phase1" && phase != "phase2" { + return fmt.Errorf("unknown phase head %q", phase) + } + if !validDigest(head.Digest) { + return fmt.Errorf("%s head digest is not a canonical SHA-256 digest", phase) + } + } + if p.Terminal != nil { + if p.Terminal.Outcome != "go" && p.Terminal.Outcome != "no-go" { + return fmt.Errorf("terminal outcome %q is not go or no-go", p.Terminal.Outcome) + } + if !validDigest(p.Terminal.Digest) { + return errors.New("terminal decision digest is not a canonical SHA-256 digest") + } + } + return nil +} + +type workspaceHighWaterRecord struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Position CheckpointPosition `json:"position"` +} + +// WorkspaceHighWater keeps rollback and fork state inside one role workspace. +// Different role workspaces therefore cannot accidentally suppress or advance +// each other's observations. +type WorkspaceHighWater struct { + path string + ceremonyID string + lock func(string) (func(), error) +} + +// OpenWorkspaceHighWater returns a workspace-scoped checkpoint high-water +// store. The workspace path is explicit; this API never falls back to HOME. +func OpenWorkspaceHighWater(workspace, ceremonyID string) (WorkspaceHighWater, error) { + if !filepath.IsAbs(workspace) || filepath.Clean(workspace) != workspace { + return WorkspaceHighWater{}, errors.New("workspace must be an absolute clean path") + } + if !validDigest(ceremonyID) { + return WorkspaceHighWater{}, errors.New("ceremony ID is not a canonical SHA-256 digest") + } + dir := filepath.Join(workspace, ".relay", strings.ReplaceAll(ceremonyID, ":", "-")) + if err := os.MkdirAll(dir, 0o700); err != nil { + return WorkspaceHighWater{}, err + } + if err := rejectSymlinkDirectory(dir); err != nil { + return WorkspaceHighWater{}, err + } + return WorkspaceHighWater{ + path: filepath.Join(dir, "checkpoint-high-water.json"), ceremonyID: ceremonyID, + lock: lockWorkspaceHighWater, + }, nil +} + +// Seen returns the stored position and whether this workspace has recorded one. +func (h WorkspaceHighWater) Seen() (CheckpointPosition, bool, error) { + return h.seenUnlocked() +} + +func (h WorkspaceHighWater) seenUnlocked() (CheckpointPosition, bool, error) { + raw, err := os.ReadFile(h.path) + if errors.Is(err, os.ErrNotExist) { + return CheckpointPosition{}, false, nil + } + if err != nil { + return CheckpointPosition{}, false, err + } + var record workspaceHighWaterRecord + if err := decodeStrict(raw, &record); err != nil { + return CheckpointPosition{}, false, fmt.Errorf("decode workspace high-water: %w", err) + } + if record.Schema != WorkspaceHighWaterSchema { + return CheckpointPosition{}, false, fmt.Errorf("workspace high-water schema %q, want %q", record.Schema, WorkspaceHighWaterSchema) + } + if record.CeremonyID != h.ceremonyID { + return CheckpointPosition{}, false, errors.New("workspace high-water belongs to another ceremony") + } + if err := record.Position.Validate(); err != nil { + return CheckpointPosition{}, false, fmt.Errorf("validate workspace high-water: %w", err) + } + return record.Position, true, nil +} + +// Check rejects rollback, a same-sequence fork, a gap in the ancestry walk, a +// phase-head retreat/fork, reopening a closed phase, or replacing/forgetting a +// terminal decision. Callers walk and authenticate checkpoints one at a time. +func (h WorkspaceHighWater) Check(candidate CheckpointPosition) error { + if err := candidate.Validate(); err != nil { + return err + } + unlock, err := h.acquireLock() + if err != nil { + return err + } + defer unlock() + seen, exists, err := h.seenUnlocked() + if err != nil { + return err + } + return checkHighWaterCandidate(seen, exists, candidate) +} + +func checkHighWaterCandidate(seen CheckpointPosition, exists bool, candidate CheckpointPosition) error { + if !exists { + return nil + } + if candidate.Sequence < seen.Sequence { + return fmt.Errorf("checkpoint sequence moved backwards from %d to %d", seen.Sequence, candidate.Sequence) + } + if candidate.Sequence == seen.Sequence { + if candidate.Digest != seen.Digest { + return fmt.Errorf("checkpoint sequence %d has a different digest: possible fork", candidate.Sequence) + } + if !reflect.DeepEqual(candidate, seen) { + return errors.New("the same checkpoint digest was presented with different authenticated state") + } + return nil + } + if candidate.Sequence != seen.Sequence+1 { + return fmt.Errorf("checkpoint sequence jumped from %d to %d; authenticate the missing ancestry first", seen.Sequence, candidate.Sequence) + } + if candidate.PreviousDigest != seen.Digest { + return errors.New("checkpoint does not descend from the workspace high-water digest") + } + return compareRetainedState(seen, candidate) +} + +// Record advances the high-water mark only after validation. The replacement +// is written and synced, atomically renamed, then the containing directory is +// synced so a reported success survives a normal process or machine restart. +func (h WorkspaceHighWater) Record(candidate CheckpointPosition) error { + if err := candidate.Validate(); err != nil { + return err + } + unlock, err := h.acquireLock() + if err != nil { + return err + } + defer unlock() + seen, exists, err := h.seenUnlocked() + if err != nil { + return err + } + if err := checkHighWaterCandidate(seen, exists, candidate); err != nil { + return err + } + if exists && reflect.DeepEqual(seen, candidate) { + return nil + } + record := workspaceHighWaterRecord{Schema: WorkspaceHighWaterSchema, CeremonyID: h.ceremonyID, Position: candidate} + raw, err := json.MarshalIndent(record, "", " ") + if err != nil { + return err + } + return writeFileAtomicDurable(h.path, raw, 0o600) +} + +func (h WorkspaceHighWater) acquireLock() (func(), error) { + if h.lock == nil { + return nil, errors.New("workspace high-water lock is not configured") + } + return h.lock(h.path + ".lock") +} + +func compareRetainedState(seen, candidate CheckpointPosition) error { + for phase, oldHead := range seen.PhaseHeads { + newHead, ok := candidate.PhaseHeads[phase] + if !ok { + return fmt.Errorf("%s head disappeared from checkpoint state", phase) + } + if newHead.Index < oldHead.Index { + return fmt.Errorf("%s head moved backwards from %d to %d", phase, oldHead.Index, newHead.Index) + } + if newHead.Index == oldHead.Index && newHead.Digest != oldHead.Digest { + return fmt.Errorf("%s head index %d has a different digest: possible fork", phase, newHead.Index) + } + if oldHead.Closed && !newHead.Closed { + return fmt.Errorf("%s retreated from closed to open", phase) + } + } + if seen.Terminal != nil { + if candidate.Terminal == nil { + return errors.New("terminal decision disappeared from checkpoint state") + } + if candidate.Terminal.Outcome != seen.Terminal.Outcome || candidate.Terminal.Digest != seen.Terminal.Digest { + return errors.New("terminal decision changed after it was recorded") + } + } + return nil +} + +func validateContentRef(label string, ref ContentRef) error { + if ref.Name == "" || filepath.IsAbs(ref.Name) || filepath.Clean(ref.Name) != ref.Name || ref.Name == "." || strings.HasPrefix(ref.Name, "../") || strings.Contains(ref.Name, "\\") { + return fmt.Errorf("root %s reference has an unsafe name", label) + } + if !validDigest(ref.SHA256) { + return fmt.Errorf("root %s reference has a malformed SHA-256 digest", label) + } + if ref.Size <= 0 || ref.Size > 16<<20 { + return fmt.Errorf("root %s reference has an invalid size", label) + } + return nil +} + +func validDigest(value string) bool { + if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 { + return false + } + for _, c := range strings.TrimPrefix(value, "sha256:") { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +func decodeStrict(raw []byte, value any) error { + if err := rejectDuplicateFields(json.NewDecoder(bytes.NewReader(raw))); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("unexpected trailing JSON value") + } + return err + } + return nil +} + +func rejectDuplicateFields(decoder *json.Decoder) error { + if err := inspectJSONValue(decoder); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("unexpected trailing JSON value") + } + return err + } + return nil +} + +func inspectJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delim, ok := token.(json.Delim) + if !ok { + return nil + } + switch delim { + case '{': + seen := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("JSON object key is not a string") + } + if _, exists := seen[key]; exists { + return fmt.Errorf("duplicate JSON field %q", key) + } + seen[key] = struct{}{} + if err := inspectJSONValue(decoder); err != nil { + return err + } + } + end, err := decoder.Token() + if err != nil { + return err + } + if end != json.Delim('}') { + return errors.New("malformed JSON object") + } + case '[': + for decoder.More() { + if err := inspectJSONValue(decoder); err != nil { + return err + } + } + end, err := decoder.Token() + if err != nil { + return err + } + if end != json.Delim(']') { + return errors.New("malformed JSON array") + } + default: + return errors.New("unexpected JSON delimiter") + } + return nil +} + +func rejectSymlinkDirectory(dir string) error { + info, err := os.Lstat(dir) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("workspace high-water directory is not a regular directory") + } + return nil +} + +func writeFileAtomicDurable(path string, raw []byte, mode os.FileMode) (result error) { + dir := filepath.Dir(path) + if err := rejectSymlinkDirectory(dir); err != nil { + return err + } + temp, err := os.CreateTemp(dir, ".checkpoint-high-water-*") + if err != nil { + return err + } + tempName := temp.Name() + defer func() { + if result != nil { + _ = os.Remove(tempName) + } + }() + if err := temp.Chmod(mode); err != nil { + _ = temp.Close() + return err + } + if _, err := temp.Write(raw); err != nil { + _ = temp.Close() + return err + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Rename(tempName, path); err != nil { + return err + } + directory, err := os.Open(dir) + if err != nil { + return err + } + defer directory.Close() + return directory.Sync() +} diff --git a/internal/state/v2_test.go b/internal/state/v2_test.go new file mode 100644 index 0000000..2475621 --- /dev/null +++ b/internal/state/v2_test.go @@ -0,0 +1,295 @@ +package state + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func digestOf(c string) string { return "sha256:" + strings.Repeat(c, 64) } + +func validRoot() Root { + return Root{ + Schema: RootSchema, + CeremonyID: digestOf("1"), + Checkpoint: ContentRef{Name: "checkpoints/0003.json", SHA256: digestOf("2"), Size: 1200}, + CheckpointSignature: ContentRef{Name: "checkpoints/0003.sig", SHA256: digestOf("3"), Size: 300}, + } +} + +func TestWorkspaceHighWaterConcurrentStaleWriterCannotOverwriteNewerPosition(t *testing.T) { + highWater := openWorkspaceHighWater(t) + cp1 := checkpoint(1, digestOf("1"), digestOf("0"), 0) + if err := highWater.Record(cp1); err != nil { + t.Fatal(err) + } + cp2 := checkpoint(2, digestOf("2"), cp1.Digest, 1) + cp3 := checkpoint(3, digestOf("3"), cp2.Digest, 1) + + // This writer computed cp2 from the same older workspace view, then pauses + // immediately before entering the transaction lock. A second sync advances + // through cp2 to cp3 first. When released, the stale writer must re-read cp3 + // while holding the lock and reject its cp2 instead of overwriting cp3. + stale := highWater + staleReady := make(chan struct{}) + releaseStale := make(chan struct{}) + stale.lock = func(path string) (func(), error) { + close(staleReady) + <-releaseStale + return lockWorkspaceHighWater(path) + } + staleResult := make(chan error, 1) + go func() { staleResult <- stale.Record(cp2) }() + + select { + case <-staleReady: + case <-time.After(5 * time.Second): + t.Fatal("stale writer did not reach the deterministic race boundary") + } + if err := highWater.Record(cp2); err != nil { + t.Fatal(err) + } + if err := highWater.Record(cp3); err != nil { + t.Fatal(err) + } + close(releaseStale) + select { + case err := <-staleResult: + if err == nil || !strings.Contains(err.Error(), "moved backwards") { + t.Fatalf("stale cp2 result = %v, want rollback rejection", err) + } + case <-time.After(5 * time.Second): + t.Fatal("stale writer did not finish") + } + seen, exists, err := highWater.Seen() + if err != nil || !exists { + t.Fatalf("Seen = %+v, %v, %v", seen, exists, err) + } + if !reflect.DeepEqual(seen, cp3) { + t.Fatalf("stale writer replaced cp3: got %+v, want %+v", seen, cp3) + } +} + +func TestRootRoundTripAndKey(t *testing.T) { + raw, err := validRoot().Encode() + if err != nil { + t.Fatal(err) + } + got, err := DecodeRoot(raw) + if err != nil { + t.Fatal(err) + } + if got.Checkpoint.SHA256 != digestOf("2") { + t.Fatalf("checkpoint digest = %q", got.Checkpoint.SHA256) + } + if key := RootKey(got.CeremonyID); key != "state/"+strings.Repeat("1", 64)+"/root.json" { + t.Fatalf("RootKey = %q", key) + } +} + +func TestRootRejectsMalformedOrUnsafeValues(t *testing.T) { + cases := map[string]func(*Root){ + "schema": func(r *Root) { r.Schema = "relay-state-v1" }, + "ceremony": func(r *Root) { r.CeremonyID = "sha256:ABC" }, + "checkpoint digest": func(r *Root) { r.Checkpoint.SHA256 = digestOf("g") }, + "absolute name": func(r *Root) { r.Checkpoint.Name = "/checkpoint.json" }, + "traversal": func(r *Root) { r.CheckpointSignature.Name = "../checkpoint.sig" }, + "zero size": func(r *Root) { r.Checkpoint.Size = 0 }, + "oversized": func(r *Root) { r.Checkpoint.Size = 16<<20 + 1 }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + root := validRoot() + mutate(&root) + if err := root.Validate(); err == nil { + t.Fatal("malformed root accepted") + } + }) + } + raw, _ := json.Marshal(validRoot()) + raw = append(raw[:len(raw)-1], []byte(`,"extra":true}`)...) + if _, err := DecodeRoot(raw); err == nil { + t.Fatal("unknown root field accepted") + } + duplicate := []byte(`{"schema":"relay-state-root-v2","schema":"relay-state-root-v2"}`) + if _, err := DecodeRoot(duplicate); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("duplicate root field was not explicitly rejected: %v", err) + } +} + +func checkpoint(sequence uint64, digest, previous string, index uint64) CheckpointPosition { + return CheckpointPosition{ + Sequence: sequence, + Digest: digest, + PreviousDigest: previous, + PhaseHeads: map[string]PhaseHeadPosition{ + "phase1": {Index: index, Digest: digestOf("a")}, + }, + } +} + +func openWorkspaceHighWater(t *testing.T) WorkspaceHighWater { + t.Helper() + highWater, err := OpenWorkspaceHighWater(t.TempDir(), digestOf("1")) + if err != nil { + t.Fatal(err) + } + return highWater +} + +func TestWorkspaceHighWaterRecordsForwardAncestryDurably(t *testing.T) { + highWater := openWorkspaceHighWater(t) + first := checkpoint(7, digestOf("7"), digestOf("6"), 2) + if err := highWater.Record(first); err != nil { + t.Fatal(err) + } + second := checkpoint(8, digestOf("8"), first.Digest, 3) + second.PhaseHeads["phase1"] = PhaseHeadPosition{Index: 3, Digest: digestOf("b")} + if err := highWater.Record(second); err != nil { + t.Fatal(err) + } + + reopened, err := OpenWorkspaceHighWater(filepath.Dir(filepath.Dir(filepath.Dir(highWater.path))), digestOf("1")) + if err != nil { + t.Fatal(err) + } + seen, exists, err := reopened.Seen() + if err != nil || !exists { + t.Fatalf("Seen = %+v, %v, %v", seen, exists, err) + } + if seen.Sequence != 8 || seen.Digest != digestOf("8") { + t.Fatalf("durable position = %+v", seen) + } + info, err := os.Stat(highWater.path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %o", info.Mode().Perm()) + } +} + +func TestWorkspaceHighWaterRejectsRollbackForkGapAndWrongParent(t *testing.T) { + highWater := openWorkspaceHighWater(t) + first := checkpoint(4, digestOf("4"), digestOf("3"), 2) + if err := highWater.Record(first); err != nil { + t.Fatal(err) + } + cases := map[string]CheckpointPosition{ + "rollback": checkpoint(3, digestOf("3"), digestOf("2"), 1), + "same fork": checkpoint(4, digestOf("5"), digestOf("3"), 2), + "same digest different state": func() CheckpointPosition { + p := first + p.PhaseHeads = map[string]PhaseHeadPosition{"phase1": {Index: 3, Digest: digestOf("8")}} + return p + }(), + "ancestry gap": checkpoint(6, digestOf("6"), first.Digest, 3), + "wrong parent": checkpoint(5, digestOf("5"), digestOf("9"), 3), + } + for name, candidate := range cases { + t.Run(name, func(t *testing.T) { + if err := highWater.Check(candidate); err == nil { + t.Fatal("unsafe checkpoint accepted") + } + }) + } + seen, _, err := highWater.Seen() + if err != nil { + t.Fatal(err) + } + if seen.Digest != first.Digest { + t.Fatalf("rejected checks changed high-water to %+v", seen) + } +} + +func TestWorkspaceHighWaterRejectsPhaseHeadRetreatAndFork(t *testing.T) { + highWater := openWorkspaceHighWater(t) + first := checkpoint(4, digestOf("4"), digestOf("3"), 2) + first.PhaseHeads["phase1"] = PhaseHeadPosition{Index: 2, Digest: digestOf("a"), Closed: true} + if err := highWater.Record(first); err != nil { + t.Fatal(err) + } + + cases := map[string]func(*CheckpointPosition){ + "missing": func(p *CheckpointPosition) { delete(p.PhaseHeads, "phase1") }, + "index rollback": func(p *CheckpointPosition) { + p.PhaseHeads["phase1"] = PhaseHeadPosition{Index: 1, Digest: digestOf("a"), Closed: true} + }, + "same-index fork": func(p *CheckpointPosition) { + p.PhaseHeads["phase1"] = PhaseHeadPosition{Index: 2, Digest: digestOf("b"), Closed: true} + }, + "reopened": func(p *CheckpointPosition) { + p.PhaseHeads["phase1"] = PhaseHeadPosition{Index: 2, Digest: digestOf("a")} + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + candidate := checkpoint(5, digestOf("5"), first.Digest, 2) + candidate.PhaseHeads["phase1"] = first.PhaseHeads["phase1"] + mutate(&candidate) + if err := highWater.Check(candidate); err == nil { + t.Fatal("phase-head retreat/fork accepted") + } + }) + } +} + +func TestWorkspaceHighWaterRejectsTerminalRetreatOrReplacement(t *testing.T) { + highWater := openWorkspaceHighWater(t) + first := checkpoint(9, digestOf("9"), digestOf("8"), 2) + first.Terminal = &TerminalPosition{Outcome: "go", Digest: digestOf("d")} + if err := highWater.Record(first); err != nil { + t.Fatal(err) + } + + missing := checkpoint(10, digestOf("a"), first.Digest, 2) + if err := highWater.Check(missing); err == nil { + t.Fatal("terminal decision was allowed to disappear") + } + replaced := missing + replaced.Terminal = &TerminalPosition{Outcome: "no-go", Digest: digestOf("e")} + if err := highWater.Check(replaced); err == nil { + t.Fatal("terminal decision replacement accepted") + } + publication := missing + publication.Terminal = &TerminalPosition{Outcome: "go", Digest: digestOf("d")} + if err := highWater.Record(publication); err != nil { + t.Fatalf("checkpoint retaining the terminal decision was rejected: %v", err) + } +} + +func TestWorkspaceHighWaterIsScopedAndRejectsCorruption(t *testing.T) { + root := t.TempDir() + firstWorkspace := filepath.Join(root, "participant-1") + secondWorkspace := filepath.Join(root, "participant-2") + if err := os.MkdirAll(firstWorkspace, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(secondWorkspace, 0o700); err != nil { + t.Fatal(err) + } + first, err := OpenWorkspaceHighWater(firstWorkspace, digestOf("1")) + if err != nil { + t.Fatal(err) + } + second, err := OpenWorkspaceHighWater(secondWorkspace, digestOf("1")) + if err != nil { + t.Fatal(err) + } + if err := first.Record(checkpoint(2, digestOf("2"), digestOf("1"), 1)); err != nil { + t.Fatal(err) + } + if _, exists, err := second.Seen(); err != nil || exists { + t.Fatalf("another workspace observed the first one's mark: exists=%v err=%v", exists, err) + } + if err := os.WriteFile(first.path, []byte(`{"schema":"broken"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := first.Seen(); err == nil { + t.Fatal("corrupt high-water state was treated as absent") + } +} diff --git a/internal/storagefirst/actions.go b/internal/storagefirst/actions.go new file mode 100644 index 0000000..7fd8fba --- /dev/null +++ b/internal/storagefirst/actions.go @@ -0,0 +1,270 @@ +package storagefirst + +import ( + "fmt" + "time" +) + +type Role string + +const ( + Coordinator Role = "coordinator" + Participant Role = "participant" +) + +type Action string + +const ( + ActionOpenOutbound Action = "open-outbound-turn" + ActionDownloadOutbound Action = "download-and-verify-outbound" + ActionUploadReceipt Action = "sign-and-upload-receipt" + ActionWaitReceipt Action = "wait-for-receipt" + ActionAcceptReceipt Action = "verify-and-accept-receipt" + ActionIssueGrant Action = "issue-candidate-grant" + ActionWaitGrant Action = "wait-for-candidate-grant" + ActionContribute Action = "contribute" + ActionUploadCandidate Action = "upload-candidate" + ActionWaitCandidate Action = "wait-for-candidate" + ActionAcceptCandidate Action = "verify-and-accept-candidate" + ActionConfirmAcceptance Action = "confirm-exact-candidate-acceptance" + ActionNextTurn Action = "start-next-turn" + ActionClosePhase Action = "close-phase1" +) + +type OperationKind string + +const ( + OperationOutboundDownloaded OperationKind = "outbound-downloaded" + OperationReceiptUploaded OperationKind = "receipt-uploaded" + OperationCandidateGrant OperationKind = "candidate-grant-saved" + OperationCandidateComputed OperationKind = "candidate-computed" + OperationCandidateUploaded OperationKind = "candidate-uploaded" +) + +type OperationFact struct { + Kind OperationKind `json:"kind"` + CheckpointDigest string `json:"checkpoint_digest"` + Phase string `json:"phase"` + Index int `json:"index"` + IdentityID string `json:"identity_id"` + AttemptID string `json:"attempt_id"` + ArtifactDigest string `json:"artifact_digest"` + GrantExpiresAt string `json:"grant_expires_at,omitempty"` +} + +// LocalFacts contains exact durable operation results, never unscoped +// completion booleans. +type LocalFacts struct { + Schema string `json:"schema"` + Role Role `json:"role"` + IdentityID string `json:"identity_id"` + Operations []OperationFact `json:"operations"` +} + +// AuthenticatedObservedSubmission has no exported fields. A caller can obtain +// a usable value only through AuthenticateObservedSubmission, after proof-tool +// verifies the exact signed envelope, checkpoint slot, and bounded manifest. +type AuthenticatedObservedSubmission struct { + ceremonyID string + checkpointDigest string + slot Slot + manifestDigest string +} + +// ObservedObjects is an opaque set of proof-tool-authenticated submissions. +// Its zero value safely represents no observed submissions. +type ObservedObjects struct { + submissions []AuthenticatedObservedSubmission +} + +func ObservedSubmissions(values ...AuthenticatedObservedSubmission) ObservedObjects { + return ObservedObjects{submissions: append([]AuthenticatedObservedSubmission(nil), values...)} +} + +type Recommendation struct { + Action Action + Ready bool + Reason string +} + +func RecommendPhase1(checkpoint Checkpoint, local LocalFacts, observed ObservedObjects) (Recommendation, error) { + return RecommendPhase1At(checkpoint, local, observed, time.Now().UTC()) +} + +func RecommendPhase1At(checkpoint Checkpoint, local LocalFacts, observed ObservedObjects, now time.Time) (Recommendation, error) { + if !checkpoint.authenticatedEvidence { + return Recommendation{}, fmt.Errorf("Phase 1 guidance requires a fully evidence-verified checkpoint from storage sync") + } + if local.Role != Coordinator && local.Role != Participant { + return Recommendation{}, fmt.Errorf("unsupported Phase 1 role %q", local.Role) + } + if err := validatePhase1Projection(checkpoint); err != nil { + return Recommendation{}, err + } + if err := validateLocalFacts(local); err != nil { + return Recommendation{}, err + } + stage := checkpoint.Transition + if checkpoint.Position.Sequence == 0 { + stage = "initial" + } + if local.Role == Coordinator { + switch stage { + case "initial": + if checkpoint.Phase1Closed { + return Recommendation{}, fmt.Errorf("initial checkpoint cannot have a closed phase") + } + return Recommendation{Action: ActionOpenOutbound, Ready: true, Reason: "open the next scheduled participant turn"}, nil + case "phase1-candidate-accepted": + if checkpoint.Phase1Closed { + return Recommendation{Action: ActionClosePhase, Reason: "Phase 1 is already closed"}, nil + } + if checkpoint.Phase1Accepted >= checkpoint.Phase1ScheduledTotal { + return Recommendation{Action: ActionClosePhase, Ready: true, Reason: "all scheduled Phase 1 participants have been accepted"}, nil + } + return Recommendation{Action: ActionNextTurn, Ready: true, Reason: "start the next authenticated scheduled participant turn"}, nil + case "phase1-outbound-published": + slot, ok := checkpoint.slot("receipt") + if !ok { + return Recommendation{}, fmt.Errorf("checkpoint has no exact receipt slot for the current turn") + } + if observed.matches(checkpoint, slot) { + return Recommendation{Action: ActionAcceptReceipt, Ready: true, Reason: "the exact allocated receipt is available for verification"}, nil + } + return Recommendation{Action: ActionWaitReceipt, Reason: "waiting for the exact allocated receipt manifest"}, nil + case "phase1-receipt-accepted": + slot, ok := checkpoint.slot("candidate") + if !ok { + return Recommendation{}, fmt.Errorf("checkpoint has no exact candidate slot for the current turn") + } + if !local.has(OperationCandidateGrant, checkpoint, slot, "", now) { + return Recommendation{Action: ActionIssueGrant, Ready: true, Reason: "receipt acceptance allocated the candidate attempt"}, nil + } + if observed.matches(checkpoint, slot) { + return Recommendation{Action: ActionAcceptCandidate, Ready: true, Reason: "the exact allocated candidate is available for verification"}, nil + } + return Recommendation{Action: ActionWaitCandidate, Reason: "waiting for the exact allocated candidate manifest"}, nil + default: + return Recommendation{}, fmt.Errorf("unsupported checkpoint transition %q", stage) + } + } + if checkpoint.ParticipantID != "" && checkpoint.ParticipantID != local.IdentityID { + return Recommendation{Action: ActionWaitReceipt, Reason: "another participant is assigned to the current turn"}, nil + } + switch stage { + case "initial": + return Recommendation{Action: ActionDownloadOutbound, Reason: "the coordinator has not opened this turn yet"}, nil + case "phase1-outbound-published": + slot, ok := checkpoint.slot("receipt") + if !ok { + return Recommendation{}, fmt.Errorf("checkpoint has no exact receipt slot for the current turn") + } + if !local.has(OperationOutboundDownloaded, checkpoint, slot, "", now) { + return Recommendation{Action: ActionDownloadOutbound, Ready: true, Reason: "download and authenticate the exact files named by the checkpoint"}, nil + } + if !local.has(OperationReceiptUploaded, checkpoint, slot, "", now) { + return Recommendation{Action: ActionUploadReceipt, Ready: true, Reason: "confirm receipt before contribution is authorized"}, nil + } + return Recommendation{Action: ActionWaitGrant, Reason: "the exact receipt is uploaded but not yet accepted"}, nil + case "phase1-receipt-accepted": + slot, ok := checkpoint.slot("candidate") + if !ok { + return Recommendation{}, fmt.Errorf("checkpoint has no exact candidate slot for the current turn") + } + if !local.has(OperationCandidateGrant, checkpoint, slot, "", now) { + return Recommendation{Action: ActionWaitGrant, Reason: "the coordinator must deliver an unexpired private grant for this exact attempt"}, nil + } + computed := local.fact(OperationCandidateComputed, checkpoint, slot, "", now) + if computed == nil { + return Recommendation{Action: ActionContribute, Ready: true, Reason: "receipt is accepted and the exact candidate grant is present"}, nil + } + if !local.has(OperationCandidateUploaded, checkpoint, slot, computed.ArtifactDigest, now) { + return Recommendation{Action: ActionUploadCandidate, Ready: true, Reason: "resume the retained exact candidate upload without recomputing"}, nil + } + return Recommendation{Action: ActionConfirmAcceptance, Reason: "exact candidate uploaded; waiting for signed coordinator acceptance"}, nil + case "phase1-candidate-accepted": + accepted := checkpoint.AcceptedCandidate + if accepted == nil || !accepted.valid() || accepted.IdentityID != local.IdentityID || !local.hasAcceptedUpload(*accepted) { + return Recommendation{Action: ActionConfirmAcceptance, Reason: "acceptance names a different or unrecorded local candidate"}, nil + } + return Recommendation{Action: ActionConfirmAcceptance, Ready: true, Reason: "signed acceptance matches this exact attempt, candidate digest, head, and acknowledgement"}, nil + default: + return Recommendation{}, fmt.Errorf("unsupported checkpoint transition %q", stage) + } +} + +func validatePhase1Projection(checkpoint Checkpoint) error { + if !validDigest(checkpoint.Position.Digest) { + return fmt.Errorf("checkpoint projection has no authenticated digest") + } + if checkpoint.Phase1ScheduledTotal < 1 || checkpoint.Phase1ScheduledTotal > 255 || + checkpoint.Phase1Accepted < 0 || checkpoint.Phase1Accepted > checkpoint.Phase1ScheduledTotal { + return fmt.Errorf("checkpoint projection has an invalid Phase 1 schedule position") + } + if checkpoint.Phase1Closed { + if checkpoint.Phase1NextParticipantID != "" { + return fmt.Errorf("closed Phase 1 projection still names a next participant") + } + } else if checkpoint.Phase1Accepted < checkpoint.Phase1ScheduledTotal && !validComponent(checkpoint.Phase1NextParticipantID) { + return fmt.Errorf("open Phase 1 projection does not name the next scheduled participant") + } + if checkpoint.Transition == "phase1-candidate-accepted" && (checkpoint.AcceptedCandidate == nil || !checkpoint.AcceptedCandidate.valid()) { + return fmt.Errorf("candidate-accepted checkpoint lacks an exact accepted-candidate projection") + } + return nil +} + +func (c Checkpoint) slot(kind string) (Slot, bool) { + for _, slot := range c.Slots { + if slot.Kind == kind && slot.Phase == "phase1" && slot.Index == c.Phase1Accepted+1 && slot.IdentityID == c.ParticipantID { + return slot, true + } + } + return Slot{}, false +} + +func (o ObservedObjects) matches(checkpoint Checkpoint, slot Slot) bool { + for _, seen := range o.submissions { + if seen.ceremonyID == checkpoint.CeremonyID && seen.checkpointDigest == checkpoint.Position.Digest && + seen.slot == slot && validDigest(seen.manifestDigest) { + return true + } + } + return false +} + +func (l LocalFacts) fact(kind OperationKind, checkpoint Checkpoint, slot Slot, artifactDigest string, now time.Time) *OperationFact { + for i := range l.Operations { + fact := &l.Operations[i] + if fact.Kind != kind || fact.CheckpointDigest != checkpoint.Position.Digest || fact.Phase != slot.Phase || fact.Index != slot.Index || fact.IdentityID != slot.IdentityID || fact.AttemptID != slot.AttemptID || (artifactDigest != "" && fact.ArtifactDigest != artifactDigest) { + continue + } + if kind == OperationCandidateGrant { + expires, _ := time.Parse(time.RFC3339, fact.GrantExpiresAt) + if !expires.After(now.UTC()) { + continue + } + } + return fact + } + return nil +} + +func (l LocalFacts) has(kind OperationKind, checkpoint Checkpoint, slot Slot, digest string, now time.Time) bool { + return l.fact(kind, checkpoint, slot, digest, now) != nil +} + +func (l LocalFacts) hasAcceptedUpload(accepted AcceptedCandidate) bool { + for _, fact := range l.Operations { + if fact.Kind == OperationCandidateUploaded && fact.CheckpointDigest == accepted.OperationCheckpointDigest && fact.Phase == "phase1" && fact.Index == accepted.Index && fact.IdentityID == accepted.IdentityID && fact.AttemptID == accepted.AttemptID && fact.ArtifactDigest == accepted.CandidateDigest { + return true + } + } + return false +} + +func (a AcceptedCandidate) valid() bool { + return validDigest(a.OperationCheckpointDigest) && validDigest(a.BasisCheckpointDigest) && + a.Index > 0 && a.Index <= 255 && validComponent(a.IdentityID) && validAttempt(a.AttemptID) && + validDigest(a.CandidateDigest) && validDigest(a.AcceptedHeadID) && validDigest(a.AcknowledgementDigest) +} diff --git a/internal/storagefirst/actions_test.go b/internal/storagefirst/actions_test.go new file mode 100644 index 0000000..336afd3 --- /dev/null +++ b/internal/storagefirst/actions_test.go @@ -0,0 +1,192 @@ +package storagefirst + +import ( + "testing" + "time" + + "github.com/zksecurity/relay/internal/state" +) + +var recommendationTime = time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC) + +func actionCheckpoint(sequence uint64, transition, participant string) Checkpoint { + cp := Checkpoint{ + Position: state.CheckpointPosition{Sequence: sequence, Digest: digestOfTest(string(rune('1' + sequence)))}, + Transition: transition, ParticipantID: participant, Phase1ScheduledTotal: 2, + Phase1NextParticipantID: participant, + } + cp.authenticatedEvidence = true + if transition == "phase1-outbound-published" { + cp.Slots = []Slot{testSlot("receipt", 1, participant, "a", cp.Position.Digest)} + } + if transition == "phase1-receipt-accepted" { + cp.Slots = []Slot{testSlot("candidate", 1, participant, "b", cp.Position.Digest)} + } + if transition == "phase1-candidate-accepted" { + cp.Phase1Accepted = 1 + cp.Phase1NextParticipantID = "participant-2" + accepted := testSlot("candidate", 1, participant, "b", digestOfTest("3")) + accepted.Status = "accepted" + accepted.AcknowledgementDigest = digestOfTest("8") + cp.Slots = []Slot{accepted} + cp.AcceptedCandidate = &AcceptedCandidate{ + OperationCheckpointDigest: digestOfTest("3"), + BasisCheckpointDigest: accepted.BasisCheckpointDigest, Index: 1, IdentityID: participant, + AttemptID: accepted.AttemptID, CandidateDigest: digestOfTest("7"), + AcceptedHeadID: digestOfTest("6"), AcknowledgementDigest: accepted.AcknowledgementDigest, + } + } + return cp +} + +func testSlot(kind string, index int, identity, attemptChar, checkpointDigest string) Slot { + attempt := string(makeHex(attemptChar, 32)) + return Slot{Kind: kind, Phase: "phase1", Index: index, IdentityID: identity, AttemptID: attempt, + ManifestKey: "submissions/" + kind + "/" + attempt + "/manifest.json", BasisCheckpointDigest: checkpointDigest, + ParentHeadID: digestOfTest("9"), Status: "allocated"} +} + +func TestRecommendRejectsShallowCheckpointProjection(t *testing.T) { + cp := actionCheckpoint(1, "phase1-outbound-published", "participant-1") + cp.authenticatedEvidence = false + _, err := RecommendPhase1At(cp, localFor(Participant, "participant-1"), ObservedObjects{}, recommendationTime) + if err == nil { + t.Fatal("shallow checkpoint drove role guidance") + } +} + +func localFor(role Role, identity string, facts ...OperationFact) LocalFacts { + return LocalFacts{Schema: LocalFactsSchema, Role: role, IdentityID: identity, Operations: facts} +} + +func operation(kind OperationKind, cp Checkpoint, slot Slot, digest string) OperationFact { + fact := OperationFact{Kind: kind, CheckpointDigest: cp.Position.Digest, Phase: slot.Phase, Index: slot.Index, + IdentityID: slot.IdentityID, AttemptID: slot.AttemptID, ArtifactDigest: digest} + if kind == OperationCandidateGrant { + fact.GrantExpiresAt = "2026-09-15T02:00:00Z" + } + return fact +} + +func observed(slot Slot) ObservedObjects { + return ObservedSubmissions(AuthenticatedObservedSubmission{ + checkpointDigest: slot.BasisCheckpointDigest, slot: slot, manifestDigest: digestOfTest("5"), + }) +} + +func TestCoordinatorRecommendationsUseExactScheduledState(t *testing.T) { + cp0 := actionCheckpoint(0, "", "participant-1") + cp1 := actionCheckpoint(1, "phase1-outbound-published", "participant-1") + cp2 := actionCheckpoint(2, "phase1-receipt-accepted", "participant-1") + grant := operation(OperationCandidateGrant, cp2, cp2.Slots[0], digestOfTest("4")) + cases := []struct { + name string + cp Checkpoint + local LocalFacts + seen ObservedObjects + want Action + ready bool + }{ + {"initial", cp0, localFor(Coordinator, "coordinator"), ObservedObjects{}, ActionOpenOutbound, true}, + {"wait exact receipt", cp1, localFor(Coordinator, "coordinator"), ObservedObjects{}, ActionWaitReceipt, false}, + {"accept exact receipt", cp1, localFor(Coordinator, "coordinator"), observed(cp1.Slots[0]), ActionAcceptReceipt, true}, + {"issue exact grant", cp2, localFor(Coordinator, "coordinator"), ObservedObjects{}, ActionIssueGrant, true}, + {"wait exact candidate", cp2, localFor(Coordinator, "coordinator", grant), ObservedObjects{}, ActionWaitCandidate, false}, + {"accept exact candidate", cp2, localFor(Coordinator, "coordinator", grant), observed(cp2.Slots[0]), ActionAcceptCandidate, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := RecommendPhase1At(tc.cp, tc.local, tc.seen, recommendationTime) + if err != nil || got.Action != tc.want || got.Ready != tc.ready { + t.Fatalf("got=%+v err=%v", got, err) + } + }) + } +} + +func TestParticipantRecommendationsRequireExactDurableFacts(t *testing.T) { + cp1 := actionCheckpoint(1, "phase1-outbound-published", "participant-1") + rslot := cp1.Slots[0] + cp2 := actionCheckpoint(2, "phase1-receipt-accepted", "participant-1") + cslot := cp2.Slots[0] + download := operation(OperationOutboundDownloaded, cp1, rslot, digestOfTest("4")) + receipt := operation(OperationReceiptUploaded, cp1, rslot, digestOfTest("5")) + grant := operation(OperationCandidateGrant, cp2, cslot, digestOfTest("6")) + computed := operation(OperationCandidateComputed, cp2, cslot, digestOfTest("7")) + uploaded := operation(OperationCandidateUploaded, cp2, cslot, computed.ArtifactDigest) + cases := []struct { + name string + cp Checkpoint + facts []OperationFact + want Action + ready bool + }{ + {"download", cp1, nil, ActionDownloadOutbound, true}, + {"receipt", cp1, []OperationFact{download}, ActionUploadReceipt, true}, + {"wait grant", cp1, []OperationFact{download, receipt}, ActionWaitGrant, false}, + {"grant required", cp2, nil, ActionWaitGrant, false}, + {"contribute", cp2, []OperationFact{grant}, ActionContribute, true}, + {"upload", cp2, []OperationFact{grant, computed}, ActionUploadCandidate, true}, + {"wait acceptance", cp2, []OperationFact{grant, computed, uploaded}, ActionConfirmAcceptance, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := RecommendPhase1At(tc.cp, localFor(Participant, "participant-1", tc.facts...), ObservedObjects{}, recommendationTime) + if err != nil || got.Action != tc.want || got.Ready != tc.ready { + t.Fatalf("got=%+v err=%v", got, err) + } + }) + } +} + +func TestStaleOrReplacementFactsDoNotAdvanceParticipant(t *testing.T) { + cp := actionCheckpoint(2, "phase1-receipt-accepted", "participant-1") + slot := cp.Slots[0] + valid := operation(OperationCandidateGrant, cp, slot, digestOfTest("4")) + tests := []func(*OperationFact){ + func(f *OperationFact) { f.CheckpointDigest = digestOfTest("f") }, func(f *OperationFact) { f.AttemptID = string(makeHex("e", 32)) }, + func(f *OperationFact) { f.IdentityID = "participant-2" }, func(f *OperationFact) { f.Index = 2 }, + func(f *OperationFact) { f.GrantExpiresAt = "2026-09-15T00:59:59Z" }, + } + for _, mutate := range tests { + changed := valid + mutate(&changed) + got, err := RecommendPhase1At(cp, localFor(Coordinator, "coordinator", changed), ObservedObjects{}, recommendationTime) + if err == nil && got.Action != ActionIssueGrant { + t.Fatalf("stale fact advanced: %+v", got) + } + } + wrongSeen := observed(slot) + wrongSeen.submissions[0].slot.AttemptID = string(makeHex("e", 32)) + got, err := RecommendPhase1At(cp, localFor(Coordinator, "coordinator", valid), wrongSeen, recommendationTime) + if err != nil || got.Action != ActionWaitCandidate { + t.Fatalf("replacement manifest advanced: %+v %v", got, err) + } +} + +func TestExactAcceptanceAndEndOfSchedule(t *testing.T) { + cp3 := actionCheckpoint(3, "phase1-candidate-accepted", "participant-1") + accepted := cp3.AcceptedCandidate + uploaded := OperationFact{Kind: OperationCandidateUploaded, CheckpointDigest: accepted.OperationCheckpointDigest, + Phase: "phase1", Index: accepted.Index, IdentityID: accepted.IdentityID, AttemptID: accepted.AttemptID, ArtifactDigest: accepted.CandidateDigest} + got, err := RecommendPhase1At(cp3, localFor(Participant, "participant-1", uploaded), ObservedObjects{}, recommendationTime) + if err != nil || !got.Ready || got.Action != ActionConfirmAcceptance { + t.Fatalf("exact acceptance: %+v %v", got, err) + } + replacement := uploaded + replacement.ArtifactDigest = digestOfTest("f") + got, _ = RecommendPhase1At(cp3, localFor(Participant, "participant-1", replacement), ObservedObjects{}, recommendationTime) + if got.Ready { + t.Fatal("different candidate treated as accepted") + } + got, _ = RecommendPhase1At(cp3, localFor(Coordinator, "coordinator"), ObservedObjects{}, recommendationTime) + if got.Action != ActionNextTurn { + t.Fatalf("next turn = %+v", got) + } + cp3.Phase1ScheduledTotal = 1 + cp3.Phase1NextParticipantID = "" + got, _ = RecommendPhase1At(cp3, localFor(Coordinator, "coordinator"), ObservedObjects{}, recommendationTime) + if got.Action != ActionClosePhase || !got.Ready { + t.Fatalf("close recommendation = %+v", got) + } +} diff --git a/internal/storagefirst/commit.go b/internal/storagefirst/commit.go new file mode 100644 index 0000000..3521144 --- /dev/null +++ b/internal/storagefirst/commit.go @@ -0,0 +1,325 @@ +package storagefirst + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +// RootWriter is the authenticated coordinator-storage surface used for the +// single mutable discovery object. +type RootWriter interface { + GetVersionedAtMost(key, localPath string, maximum int64) (store.ObjectVersion, error) + PutIfAbsent(key, localPath string) (store.ObjectVersion, error) + PutIfMatch(key, localPath string, expected store.ObjectVersion) (store.ObjectVersion, error) +} + +// AuthenticatedRootChild is an opaque proof-tool-authenticated checkpoint and +// signature pair. Callers obtain it with AuthenticateRootChild; its unexported +// fields prevent CommitRoot from accepting bare caller assertions about a +// checkpoint's parent or authenticated bytes. +type AuthenticatedRootChild struct { + checkpoint Checkpoint + checkpointRef state.ContentRef + signatureRef state.ContentRef + publicRefs []state.ContentRef +} + +// PublicationArtifacts returns the exact proof-tool-derived public files that +// must be immutable and readable before this child becomes discoverable. +func (c AuthenticatedRootChild) PublicationArtifacts() []state.ContentRef { + return append([]state.ContentRef(nil), c.publicRefs...) +} + +// PublicationVerifierV4 is the narrow proof-tool boundary used before a V4 +// checkpoint can become the discoverable storage head. Its implementation +// must authenticate the signature and full ancestry and recheck the exact +// transition evidence, including mathematical replay for candidate acceptance. +type PublicationVerifierV4 interface { + AuthenticateCheckpointForPublicationV4(root, record, signature string) (transcript.CheckpointInspectionV4, error) +} + +// AuthenticateRootChildV4 adapts proof-tool's V4 projection to the existing +// provider-independent root commit primitive. No V4 checkpoint JSON is parsed +// by Relay to manufacture the authorization. +func AuthenticateRootChildV4(verifier PublicationVerifierV4, checkpointRef, signatureRef state.ContentRef, artifactRoot, checkpointPath, signaturePath string) (AuthenticatedRootChild, error) { + if verifier == nil { + return AuthenticatedRootChild{}, errors.New("V4 checkpoint verifier is required") + } + if err := verifyLocalRef(checkpointRef, checkpointPath); err != nil { + return AuthenticatedRootChild{}, fmt.Errorf("checkpoint bytes: %w", err) + } + if err := verifyLocalRef(signatureRef, signaturePath); err != nil { + return AuthenticatedRootChild{}, fmt.Errorf("checkpoint signature bytes: %w", err) + } + inspection, err := verifier.AuthenticateCheckpointForPublicationV4(artifactRoot, checkpointPath, signaturePath) + if err != nil { + return AuthenticatedRootChild{}, fmt.Errorf("authenticate V4 checkpoint for publication: %w", err) + } + c := inspection.Checkpoint + if contentRef(inspection.CheckpointRefs.Record) != checkpointRef || contentRef(inspection.CheckpointRefs.Signature) != signatureRef { + return AuthenticatedRootChild{}, errors.New("proof-tool V4 checkpoint references do not match the exact local bytes") + } + projected := Checkpoint{ + CeremonyID: c.CeremonyID, + Position: state.CheckpointPosition{ + Sequence: c.Sequence, + Digest: checkpointRef.SHA256, + }, + Transition: c.Transition.Kind, + } + if projected.Transition == "" { + return AuthenticatedRootChild{}, errors.New("proof-tool V4 checkpoint projection has no transition kind") + } + public, err := transcript.RequiredPublicArtifactsV4(inspection) + if err != nil { + return AuthenticatedRootChild{}, fmt.Errorf("derive required public artifacts: %w", err) + } + publicRefs := make([]state.ContentRef, 0, len(public)) + for _, ref := range public { + publicRefs = append(publicRefs, contentRef(ref)) + } + if c.PreviousCheckpoint != nil { + previous := SignedRef{Checkpoint: contentRef(c.PreviousCheckpoint.Record), Signature: contentRef(c.PreviousCheckpoint.Signature)} + projected.Previous = &previous + projected.Position.PreviousDigest = previous.Checkpoint.SHA256 + } + return AuthenticatedRootChild{checkpoint: projected, checkpointRef: checkpointRef, signatureRef: signatureRef, publicRefs: publicRefs}, nil +} + +// AuthenticateRootChild binds a proof-tool-authenticated checkpoint projection +// to the exact local checkpoint and signature bytes that will be published. +// Relay does not parse the signed checkpoint to manufacture this projection. +func AuthenticateRootChild(verifier Verifier, checkpointRef, signatureRef state.ContentRef, artifactRoot, checkpointPath, signaturePath string) (AuthenticatedRootChild, error) { + if verifier == nil { + return AuthenticatedRootChild{}, errors.New("checkpoint verifier is required") + } + if err := verifyLocalRef(checkpointRef, checkpointPath); err != nil { + return AuthenticatedRootChild{}, fmt.Errorf("checkpoint bytes: %w", err) + } + if err := verifyLocalRef(signatureRef, signaturePath); err != nil { + return AuthenticatedRootChild{}, fmt.Errorf("checkpoint signature bytes: %w", err) + } + checkpoint, err := verifier.VerifyCheckpoint(checkpointPath, signaturePath) + if err != nil { + return AuthenticatedRootChild{}, fmt.Errorf("authenticate checkpoint: %w", err) + } + if checkpoint.Position.Digest != checkpointRef.SHA256 { + return AuthenticatedRootChild{}, errors.New("proof-tool checkpoint digest does not match the exact checkpoint bytes") + } + evidence, err := verifier.VerifyEvidence(artifactRoot, checkpointPath, signaturePath) + if err != nil { + return AuthenticatedRootChild{}, fmt.Errorf("fully verify checkpoint evidence: %w", err) + } + if !evidence.FullyVerified || evidence.CeremonyID != checkpoint.CeremonyID || + evidence.Sequence != checkpoint.Position.Sequence || evidence.Digest != checkpoint.Position.Digest || + evidence.TransitionKind != checkpoint.Transition { + return AuthenticatedRootChild{}, errors.New("full evidence verification does not match the authenticated checkpoint") + } + return AuthenticatedRootChild{checkpoint: checkpoint, checkpointRef: checkpointRef, signatureRef: signatureRef}, nil +} + +// RootCommit describes one authenticated child and the exact root/version it +// descends from. The immutable child bytes must already be uploaded before this +// call; changing root only makes those existing bytes discoverable. +type RootCommit struct { + CeremonyID string + Child AuthenticatedRootChild + // Previous is nil only for initialization. Later commits must carry the + // exact root/version read before preparing and signing the child. + Previous *state.Root + PreviousVersion *store.ObjectVersion +} + +// RootCommitStatus is the result of rereading the mutable discovery root after +// an attempted conditional update. Only RootCommitConfirmed is safe to record +// as a completed publication. +type RootCommitStatus string + +const ( + RootCommitConfirmed RootCommitStatus = "confirmed" + RootCommitStillPrior RootCommitStatus = "still-prior" + RootCommitUnexpected RootCommitStatus = "unexpected" +) + +var ErrRootCommitUnconfirmed = errors.New("root commit was not confirmed by an exact reread") + +// CommitRoot conditionally installs one discovery root. A conflict is never +// retried with a new parent: the caller must synchronize and determine whether +// its exact checkpoint committed or a competing child won. +func CommitRoot(objects RootWriter, commit RootCommit, tempParent string) (store.ObjectVersion, error) { + if objects == nil { + return store.ObjectVersion{}, errors.New("root writer is required") + } + next := state.Root{ + Schema: state.RootSchema, + CeremonyID: commit.CeremonyID, + Checkpoint: commit.Child.checkpointRef, + CheckpointSignature: commit.Child.signatureRef, + } + raw, err := next.Encode() + if err != nil { + return store.ObjectVersion{}, err + } + if (commit.Previous == nil) != (commit.PreviousVersion == nil) { + return store.ObjectVersion{}, errors.New("previous root and previous version must be supplied together") + } + if commit.Child.checkpoint.CeremonyID != commit.CeremonyID { + return store.ObjectVersion{}, errors.New("authenticated child belongs to another ceremony") + } + if commit.Previous != nil { + if err := commit.Previous.Validate(); err != nil { + return store.ObjectVersion{}, fmt.Errorf("previous root: %w", err) + } + if commit.Previous.CeremonyID != commit.CeremonyID { + return store.ObjectVersion{}, errors.New("previous root belongs to another ceremony") + } + if commit.Previous.Checkpoint.SHA256 == commit.Child.checkpointRef.SHA256 { + return store.ObjectVersion{}, errors.New("new root must advance to a different checkpoint") + } + if commit.Child.checkpoint.Previous == nil || + commit.Child.checkpoint.Previous.Checkpoint != commit.Previous.Checkpoint || + commit.Child.checkpoint.Previous.Signature != commit.Previous.CheckpointSignature { + return store.ObjectVersion{}, errors.New("authenticated child does not descend from the exact checkpoint and signature named by the previous root") + } + if commit.Child.checkpoint.Position.PreviousDigest != commit.Previous.Checkpoint.SHA256 { + return store.ObjectVersion{}, errors.New("authenticated child position does not bind the previous root checkpoint digest") + } + if err := RootStillNames(objects, *commit.Previous, *commit.PreviousVersion, tempParent); err != nil { + return store.ObjectVersion{}, fmt.Errorf("recheck previous root before commit: %w", err) + } + } else if commit.Child.checkpoint.Position.Sequence != 0 || commit.Child.checkpoint.Previous != nil { + return store.ObjectVersion{}, errors.New("initial root requires an authenticated sequence-zero checkpoint with no predecessor") + } + temp, err := os.MkdirTemp(tempParent, "relay-root-commit-") + if err != nil { + return store.ObjectVersion{}, err + } + defer os.RemoveAll(temp) + // Root publication is last. Refuse to make a child discoverable until both + // immutable objects can be reread and matched byte-for-byte from storage. + if err := fetchExact(objects, commit.Child.checkpointRef, filepath.Join(temp, "checkpoint.json")); err != nil { + return store.ObjectVersion{}, fmt.Errorf("verify uploaded checkpoint: %w", err) + } + if err := fetchExact(objects, commit.Child.signatureRef, filepath.Join(temp, "checkpoint.sig")); err != nil { + return store.ObjectVersion{}, fmt.Errorf("verify uploaded checkpoint signature: %w", err) + } + rootPath := filepath.Join(temp, "root.json") + if err := os.WriteFile(rootPath, raw, 0o600); err != nil { + return store.ObjectVersion{}, err + } + key := state.RootKey(commit.CeremonyID) + var committed store.ObjectVersion + if commit.Previous == nil { + committed, err = objects.PutIfAbsent(key, rootPath) + } else { + committed, err = objects.PutIfMatch(key, rootPath, *commit.PreviousVersion) + } + if err != nil { + return store.ObjectVersion{}, err + } + status, observed, err := ReconcileRootCommit(objects, commit, committed, tempParent) + if err != nil { + return store.ObjectVersion{}, fmt.Errorf("%w: %v", ErrRootCommitUnconfirmed, err) + } + if status != RootCommitConfirmed { + return store.ObjectVersion{}, fmt.Errorf("%w: storage reread status %s", ErrRootCommitUnconfirmed, status) + } + return observed, nil +} + +// ReconcileRootCommit classifies the exact root currently visible through the +// authenticated provider API. It is safe to call after a crash with the +// intended write version retained in the coordinator journal. A caller may +// proceed only for RootCommitConfirmed; StillPrior means the conditional write +// did not become visible, and Unexpected means some other root/version won. +func ReconcileRootCommit(objects RootWriter, commit RootCommit, intendedVersion store.ObjectVersion, tempParent string) (RootCommitStatus, store.ObjectVersion, error) { + if objects == nil { + return RootCommitUnexpected, store.ObjectVersion{}, errors.New("root writer is required") + } + next := state.Root{ + Schema: state.RootSchema, CeremonyID: commit.CeremonyID, + Checkpoint: commit.Child.checkpointRef, CheckpointSignature: commit.Child.signatureRef, + } + if err := next.Validate(); err != nil { + return RootCommitUnexpected, store.ObjectVersion{}, err + } + temp, err := os.MkdirTemp(tempParent, "relay-root-reconcile-") + if err != nil { + return RootCommitUnexpected, store.ObjectVersion{}, err + } + defer os.RemoveAll(temp) + rootPath := filepath.Join(temp, "root.json") + observedVersion, err := objects.GetVersionedAtMost(state.RootKey(commit.CeremonyID), rootPath, maxRootBytes) + if err != nil { + return RootCommitUnexpected, store.ObjectVersion{}, err + } + raw, err := os.ReadFile(rootPath) + if err != nil { + return RootCommitUnexpected, store.ObjectVersion{}, err + } + observed, err := state.DecodeRoot(raw) + if err != nil { + return RootCommitUnexpected, observedVersion, nil + } + if observed == next && sameRootVersion(observedVersion, intendedVersion) { + return RootCommitConfirmed, observedVersion, nil + } + if commit.Previous != nil && observed == *commit.Previous && commit.PreviousVersion != nil && sameRootVersion(observedVersion, *commit.PreviousVersion) { + return RootCommitStillPrior, observedVersion, nil + } + return RootCommitUnexpected, observedVersion, nil +} + +func sameRootVersion(observed, expected store.ObjectVersion) bool { + return observed.ETag == expected.ETag && observed.Size == expected.Size && + (!usableVersionID(observed.VersionID) || !usableVersionID(expected.VersionID) || observed.VersionID == expected.VersionID) +} + +// Some S3-compatible providers return a write receipt in VersionId but do not +// expose that value through HEAD/GET because bucket versioning is unavailable. +// ETag plus exact bytes remain the conditional-write and authentication +// boundary in that case. When both reads expose real version IDs, they must +// still agree. +func usableVersionID(value string) bool { + return value != "" && value != "null" +} + +// RootStillNames returns nil only when a new pinned read yields the exact root +// and provider version used to authorize an operation. Call immediately before +// signing, granting, accepting, or publishing. +func RootStillNames(objects RootWriter, expected state.Root, expectedVersion store.ObjectVersion, tempParent string) error { + if objects == nil { + return errors.New("root reader is required") + } + temp, err := os.MkdirTemp(tempParent, "relay-root-recheck-") + if err != nil { + return err + } + defer os.RemoveAll(temp) + path := filepath.Join(temp, "root.json") + version, err := objects.GetVersionedAtMost(state.RootKey(expected.CeremonyID), path, maxRootBytes) + if err != nil { + return err + } + if !sameRootVersion(version, expectedVersion) { + return store.ErrVersionConflict + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + actual, err := state.DecodeRoot(raw) + if err != nil { + return err + } + if actual != expected { + return store.ErrVersionConflict + } + return nil +} diff --git a/internal/storagefirst/commit_test.go b/internal/storagefirst/commit_test.go new file mode 100644 index 0000000..86126a6 --- /dev/null +++ b/internal/storagefirst/commit_test.go @@ -0,0 +1,427 @@ +package storagefirst + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +type publicationVerifierV4Fake struct { + inspection transcript.CheckpointInspectionV4 + err error +} + +func (f publicationVerifierV4Fake) AuthenticateCheckpointForPublicationV4(_, _, _ string) (transcript.CheckpointInspectionV4, error) { + return f.inspection, f.err +} + +type rootWriterFake struct { + current []byte + version store.ObjectVersion + objects map[string][]byte + create int + replace int + conflict bool + // afterWrite simulates an unreliable provider after it reports a successful + // conditional write: "prior" restores the old root, "missing" makes the + // root unreadable, and "different" exposes unrelated bytes/version. + afterWrite string + lastWriteVersion store.ObjectVersion + writeVersionID string +} + +func (f *rootWriterFake) GetVersionedAtMost(key string, local string, maximum int64) (store.ObjectVersion, error) { + raw := f.current + version := f.version + if strings.HasPrefix(key, "state/") && raw == nil { + return store.ObjectVersion{}, os.ErrNotExist + } + if !strings.HasPrefix(key, "state/") { + var ok bool + raw, ok = f.objects[key] + if !ok { + return store.ObjectVersion{}, os.ErrNotExist + } + version = store.ObjectVersion{ETag: "immutable", Size: int64(len(raw))} + } + if int64(len(raw)) > maximum { + return store.ObjectVersion{}, errors.New("too large") + } + if err := os.MkdirAll(filepath.Dir(local), 0o700); err != nil { + return store.ObjectVersion{}, err + } + if err := os.WriteFile(local, raw, 0o600); err != nil { + return store.ObjectVersion{}, err + } + return version, nil +} +func (f *rootWriterFake) PutIfAbsent(_ string, local string) (store.ObjectVersion, error) { + f.create++ + if f.conflict { + return store.ObjectVersion{}, store.ErrExists + } + prior, priorVersion := append([]byte(nil), f.current...), f.version + f.current, _ = os.ReadFile(local) + f.version = store.ObjectVersion{ETag: "created", Size: int64(len(f.current))} + committed := f.version + committed.VersionID = f.writeVersionID + f.lastWriteVersion = committed + f.applyAfterWrite(prior, priorVersion) + return committed, nil +} +func (f *rootWriterFake) PutIfMatch(_ string, local string, expected store.ObjectVersion) (store.ObjectVersion, error) { + f.replace++ + if f.conflict || expected.ETag != f.version.ETag { + return store.ObjectVersion{}, store.ErrVersionConflict + } + prior, priorVersion := append([]byte(nil), f.current...), f.version + f.current, _ = os.ReadFile(local) + f.version = store.ObjectVersion{ETag: "replaced", Size: int64(len(f.current))} + committed := f.version + committed.VersionID = f.writeVersionID + f.lastWriteVersion = committed + f.applyAfterWrite(prior, priorVersion) + return committed, nil +} + +func (f *rootWriterFake) applyAfterWrite(prior []byte, priorVersion store.ObjectVersion) { + switch f.afterWrite { + case "prior": + f.current, f.version = prior, priorVersion + case "missing": + f.current, f.version = nil, store.ObjectVersion{} + case "different": + f.current = []byte(`{"schema":"relay-state-root-v2","ceremony_id":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","checkpoint":{"name":"checkpoints/other.json","sha256":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1},"checkpoint_signature":{"name":"checkpoints/other.sig","sha256":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","size":1}}`) + f.version = store.ObjectVersion{ETag: "different", Size: int64(len(f.current))} + } +} + +func TestCommitRootCreatesThenConditionallyAdvances(t *testing.T) { + ceremonyID := digestOfTest("1") + w := &rootWriterFake{objects: make(map[string][]byte)} + verifier := &verifierFake{byDigest: make(map[string]Checkpoint)} + first, firstSig, child0 := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint-0"), []byte("signature-0"), nil, 0) + created, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: child0}, t.TempDir()) + if err != nil || w.create != 1 || w.replace != 0 { + t.Fatalf("created=%+v err=%v calls=%d/%d", created, err, w.create, w.replace) + } + previous, err := state.DecodeRoot(w.current) + if err != nil { + t.Fatal(err) + } + _, _, child1 := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint-1"), []byte("signature-1"), &SignedRef{Checkpoint: first, Signature: firstSig}, 1) + if _, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: child1, Previous: &previous, PreviousVersion: &created}, t.TempDir()); err != nil { + t.Fatal(err) + } + if w.replace != 1 { + t.Fatal("root was not conditionally replaced") + } +} + +func TestCommitRootAcceptsWriteOnlyProviderVersionID(t *testing.T) { + ceremonyID := digestOfTest("1") + w := &rootWriterFake{objects: make(map[string][]byte), writeVersionID: "write-receipt-not-returned-by-head"} + verifier := &verifierFake{byDigest: make(map[string]Checkpoint)} + _, _, child := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint"), []byte("signature"), nil, 0) + version, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: child}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if version.VersionID != "" || version.ETag != "created" { + t.Fatalf("commit should retain the authenticated reread version: %+v", version) + } + if sameRootVersion(store.ObjectVersion{ETag: "same", VersionID: "one", Size: 1}, store.ObjectVersion{ETag: "same", VersionID: "two", Size: 1}) { + t.Fatal("two distinct readable provider versions were treated as equal") + } +} + +func TestCommitRootDoesNotReturnCommittedUntilExactReread(t *testing.T) { + for _, behavior := range []string{"missing", "different"} { + t.Run(behavior, func(t *testing.T) { + ceremonyID := digestOfTest("1") + w := &rootWriterFake{objects: make(map[string][]byte), afterWrite: behavior} + verifier := &verifierFake{byDigest: make(map[string]Checkpoint)} + _, _, child := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint"), []byte("signature"), nil, 0) + if _, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: child}, t.TempDir()); !errors.Is(err, ErrRootCommitUnconfirmed) { + t.Fatalf("post-write %s error = %v", behavior, err) + } + }) + } +} + +func TestCommitRootClassifiesPriorRootAfterReportedReplacement(t *testing.T) { + ceremonyID := digestOfTest("1") + w := &rootWriterFake{objects: make(map[string][]byte)} + verifier := &verifierFake{byDigest: make(map[string]Checkpoint)} + first, firstSig, child0 := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint-0"), []byte("signature-0"), nil, 0) + created, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: child0}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + previous, err := state.DecodeRoot(w.current) + if err != nil { + t.Fatal(err) + } + _, _, child1 := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint-1"), []byte("signature-1"), &SignedRef{Checkpoint: first, Signature: firstSig}, 1) + commit := RootCommit{CeremonyID: ceremonyID, Child: child1, Previous: &previous, PreviousVersion: &created} + w.afterWrite = "prior" + if _, err := CommitRoot(w, commit, t.TempDir()); !errors.Is(err, ErrRootCommitUnconfirmed) { + t.Fatalf("prior-root error = %v", err) + } + status, _, err := ReconcileRootCommit(w, commit, w.lastWriteVersion, t.TempDir()) + if err != nil || status != RootCommitStillPrior { + t.Fatalf("reconcile status=%s err=%v", status, err) + } +} + +func TestReconcileRootCommitDistinguishesUnexpectedRoot(t *testing.T) { + ceremonyID := digestOfTest("1") + w := &rootWriterFake{objects: make(map[string][]byte)} + verifier := &verifierFake{byDigest: make(map[string]Checkpoint)} + _, _, child := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint"), []byte("signature"), nil, 0) + commit := RootCommit{CeremonyID: ceremonyID, Child: child} + w.current = []byte(`{"schema":"relay-state-root-v2","ceremony_id":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","checkpoint":{"name":"checkpoints/other.json","sha256":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1},"checkpoint_signature":{"name":"checkpoints/other.sig","sha256":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","size":1}}`) + w.version = store.ObjectVersion{ETag: "different", Size: int64(len(w.current))} + status, _, err := ReconcileRootCommit(w, commit, store.ObjectVersion{ETag: "created", Size: 1}, t.TempDir()) + if err != nil || status != RootCommitUnexpected { + t.Fatalf("reconcile status=%s err=%v", status, err) + } +} + +func TestCommitRootPreservesConflict(t *testing.T) { + w := &rootWriterFake{conflict: true, objects: make(map[string][]byte)} + verifier := &verifierFake{byDigest: make(map[string]Checkpoint)} + ceremonyID := digestOfTest("1") + _, _, child := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint"), []byte("signature"), nil, 0) + _, err := CommitRoot(w, RootCommit{ + CeremonyID: ceremonyID, + Child: child, + }, t.TempDir()) + if !errors.Is(err, store.ErrExists) { + t.Fatalf("err=%v", err) + } +} + +func TestRootStillNamesDetectsChangedVersionOrBytes(t *testing.T) { + w := &rootWriterFake{objects: make(map[string][]byte)} + ceremonyID := digestOfTest("1") + verifier := &verifierFake{byDigest: make(map[string]Checkpoint)} + _, _, child := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint"), []byte("signature"), nil, 0) + version, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: child}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + root, _ := state.DecodeRoot(w.current) + if err := RootStillNames(w, root, version, t.TempDir()); err != nil { + t.Fatal(err) + } + w.version.ETag = "other" + if !errors.Is(RootStillNames(w, root, version, t.TempDir()), store.ErrVersionConflict) { + t.Fatal("changed root version accepted") + } +} + +func TestCommitRootRejectsUnrelatedForkWithCorrectETag(t *testing.T) { + ceremonyID := digestOfTest("1") + w := &rootWriterFake{objects: make(map[string][]byte)} + verifier := &verifierFake{byDigest: make(map[string]Checkpoint)} + first, firstSig, child0 := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint-0"), []byte("signature-0"), nil, 0) + created, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: child0}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + previous, _ := state.DecodeRoot(w.current) + unrelated := SignedRef{ + Checkpoint: state.ContentRef{Name: "checkpoints/unrelated.json", SHA256: digestOfTest("a"), Size: 1}, + Signature: state.ContentRef{Name: "checkpoints/unrelated.sig", SHA256: digestOfTest("b"), Size: 1}, + } + _, _, fork := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("fork"), []byte("fork-signature"), &unrelated, 1) + if _, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: fork, Previous: &previous, PreviousVersion: &created}, t.TempDir()); err == nil { + t.Fatal("unrelated authenticated child replaced the root") + } + if w.replace != 0 || previous.Checkpoint != first || previous.CheckpointSignature != firstSig { + t.Fatal("rejected fork changed the root") + } +} + +func TestCommitRootRequiresExactParentSignatureAndRootVersionPair(t *testing.T) { + ceremonyID := digestOfTest("1") + w := &rootWriterFake{objects: make(map[string][]byte)} + verifier := &verifierFake{byDigest: make(map[string]Checkpoint)} + first, firstSig, child0 := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint-0"), []byte("signature-0"), nil, 0) + created, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: child0}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + previous, _ := state.DecodeRoot(w.current) + wrongSignature := firstSig + wrongSignature.SHA256 = digestOfTest("e") + _, _, child := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint-1"), []byte("signature-1"), &SignedRef{Checkpoint: first, Signature: wrongSignature}, 1) + if _, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: child, Previous: &previous, PreviousVersion: &created}, t.TempDir()); err == nil { + t.Fatal("child naming a different parent signature replaced the root") + } + + _, _, correctChild := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint-2"), []byte("signature-2"), &SignedRef{Checkpoint: first, Signature: firstSig}, 1) + wrongVersion := created + wrongVersion.ETag = "not-the-version-of-previous" + if _, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: correctChild, Previous: &previous, PreviousVersion: &wrongVersion}, t.TempDir()); !errors.Is(err, store.ErrVersionConflict) { + t.Fatalf("mismatched previous root/version pair error=%v", err) + } +} + +func TestCommitRootRequiresExactImmutableChildBytesBeforeCAS(t *testing.T) { + ceremonyID := digestOfTest("1") + w := &rootWriterFake{objects: make(map[string][]byte)} + verifier := &verifierFake{byDigest: make(map[string]Checkpoint)} + _, _, child := authenticatedCommitChild(t, w, verifier, ceremonyID, []byte("checkpoint"), []byte("signature"), nil, 0) + delete(w.objects, store.Key(child.signatureRef.SHA256)) + if _, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: child}, t.TempDir()); err == nil { + t.Fatal("root committed before its immutable signature existed") + } + if w.create != 0 { + t.Fatal("root create was attempted before immutable verification") + } + w.objects[store.Key(child.signatureRef.SHA256)] = []byte("wrong-signature") + if _, err := CommitRoot(w, RootCommit{CeremonyID: ceremonyID, Child: child}, t.TempDir()); err == nil { + t.Fatal("root committed with wrong immutable signature bytes") + } +} + +func TestAuthenticateRootChildRejectsReferenceOrVerifierMismatch(t *testing.T) { + dir := t.TempDir() + cpPath, sigPath := filepath.Join(dir, "checkpoint.json"), filepath.Join(dir, "checkpoint.sig") + if err := os.WriteFile(cpPath, []byte("checkpoint"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sigPath, []byte("signature"), 0o600); err != nil { + t.Fatal(err) + } + cpRef, sigRef := ref("checkpoints/0.json", []byte("checkpoint")), ref("checkpoints/0.sig", []byte("signature")) + verifier := &verifierFake{byDigest: map[string]Checkpoint{cpRef.SHA256: {CeremonyID: digestOfTest("1"), Position: state.CheckpointPosition{Sequence: 0, Digest: digestOfTest("f")}}}} + if _, err := AuthenticateRootChild(verifier, cpRef, sigRef, dir, cpPath, sigPath); err == nil { + t.Fatal("proof-tool digest mismatch accepted") + } + cpRef.Size++ + if _, err := AuthenticateRootChild(verifier, cpRef, sigRef, dir, cpPath, sigPath); err == nil { + t.Fatal("local checkpoint reference mismatch accepted") + } +} + +func TestAuthenticateRootChildRejectsShallowCheckpointOnly(t *testing.T) { + dir := t.TempDir() + cpBytes, sigBytes := []byte("checkpoint"), []byte("signature") + cpPath, sigPath := filepath.Join(dir, "checkpoint.json"), filepath.Join(dir, "checkpoint.sig") + if err := os.WriteFile(cpPath, cpBytes, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sigPath, sigBytes, 0o600); err != nil { + t.Fatal(err) + } + cpRef, sigRef := ref("checkpoints/0.json", cpBytes), ref("checkpoints/0.sig", sigBytes) + ceremonyID := digestOfTest("1") + verifier := &verifierFake{byDigest: map[string]Checkpoint{ + cpRef.SHA256: {CeremonyID: ceremonyID, Position: state.CheckpointPosition{Digest: cpRef.SHA256}, Transition: "initial"}, + }, rejectEvidence: true} + if _, err := AuthenticateRootChild(verifier, cpRef, sigRef, dir, cpPath, sigPath); err == nil || !strings.Contains(err.Error(), "bad stored evidence") { + t.Fatalf("err=%v", err) + } +} + +func TestAuthenticateRootChildV4ProjectsOnlyFullyCheckedToolResult(t *testing.T) { + dir := t.TempDir() + cpBytes, sigBytes := []byte("checkpoint-v4"), []byte("signature-v4") + cpPath, sigPath := filepath.Join(dir, "checkpoint.json"), filepath.Join(dir, "checkpoint.sig") + if err := os.WriteFile(cpPath, cpBytes, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sigPath, sigBytes, 0o600); err != nil { + t.Fatal(err) + } + cpRef, sigRef := ref("checkpoints/4/checkpoint.json", cpBytes), ref("checkpoints/4/checkpoint.sig", sigBytes) + pair := transcript.SignedArtifactRefs{ + Record: transcript.ArtifactRef{Name: cpRef.Name, Digest: transcript.Digest{SHA256: cpRef.SHA256, Blake2b256: "blake2b256:" + strings.Repeat("a", 64), Size: cpRef.Size}}, + Signature: transcript.ArtifactRef{Name: sigRef.Name, Digest: transcript.Digest{SHA256: sigRef.SHA256, Blake2b256: "blake2b256:" + strings.Repeat("b", 64), Size: sigRef.Size}}, + } + publicPair := func(base, seed string) transcript.SignedArtifactRefs { + return transcript.SignedArtifactRefs{ + Record: transcript.ArtifactRef{Name: base + ".json", Digest: transcript.Digest{SHA256: digestOfTest(seed), Blake2b256: "blake2b256:" + strings.Repeat(seed, 64), Size: 10}}, + Signature: transcript.ArtifactRef{Name: base + ".sig", Digest: transcript.Digest{SHA256: digestOfTest(seed + "1"), Blake2b256: "blake2b256:" + strings.Repeat(seed+"1", 32), Size: 64}}, + } + } + definition := publicPair("ceremony", "2") + chain := publicPair("phase1/chain-0000", "3") + head := transcript.ArtifactRef{Name: "phase1/genesis.bin", Digest: transcript.Digest{SHA256: digestOfTest("4"), Blake2b256: "blake2b256:" + strings.Repeat("4", 64), Size: 32}} + inspection := transcript.CheckpointInspectionV4{Schema: "proof-tool-mpc-checkpoint-inspection-v4", Depth: "checkpoint-structure", CheckpointRefs: pair, Commitments: transcript.CheckpointCommitmentsV4{Enrollments: []transcript.SignedArtifactRefs{}, Turns: []transcript.TurnCommitmentV4{}}} + inspection.Checkpoint.Schema = "proof-tool-mpc-checkpoint-v4" + inspection.Checkpoint.Workflow = "storage-first-v2" + inspection.Checkpoint.ReleaseVerification = "coordinator-full-replay-v1" + inspection.Checkpoint.CeremonyID = digestOfTest("1") + inspection.Checkpoint.Sequence = 4 + inspection.Checkpoint.Definition = definition + inspection.Checkpoint.Progress.Phase1 = transcript.CheckpointPhaseState{Phase: "phase1", HeadRecordID: digestOfTest("5"), HeadPayload: head, Chain: chain} + inspection.Checkpoint.AcceptedArtifacts = []transcript.ArtifactRef{} + inspection.Checkpoint.Deliveries = []transcript.DeliverySlotV4{} + inspection.Checkpoint.Transition.Kind = "phase1-candidate-accepted" + previous := pair + previous.Record.Name, previous.Record.Digest.SHA256 = "checkpoints/3/checkpoint.json", digestOfTest("c") + previous.Signature.Name, previous.Signature.Digest.SHA256 = "checkpoints/3/checkpoint.sig", digestOfTest("d") + inspection.Checkpoint.PreviousCheckpoint = &previous + child, err := AuthenticateRootChildV4(publicationVerifierV4Fake{inspection: inspection}, cpRef, sigRef, dir, cpPath, sigPath) + if err != nil { + t.Fatal(err) + } + if child.checkpoint.Position.Sequence != 4 || child.checkpoint.Previous == nil || child.checkpoint.Previous.Checkpoint.SHA256 != digestOfTest("c") || child.checkpoint.Transition != "phase1-candidate-accepted" { + t.Fatalf("bad V4 projection: %+v", child.checkpoint) + } + bad := inspection + bad.CheckpointRefs.Record.Digest.SHA256 = digestOfTest("e") + if _, err := AuthenticateRootChildV4(publicationVerifierV4Fake{inspection: bad}, cpRef, sigRef, dir, cpPath, sigPath); err == nil { + t.Fatal("mismatched V4 proof-tool projection accepted") + } +} + +func authenticatedCommitChild(t *testing.T, w *rootWriterFake, verifier *verifierFake, ceremonyID string, checkpointBytes, signatureBytes []byte, previous *SignedRef, sequence uint64) (state.ContentRef, state.ContentRef, AuthenticatedRootChild) { + t.Helper() + cpRef := ref("checkpoints/child.json", checkpointBytes) + sigRef := ref("checkpoints/child.sig", signatureBytes) + w.objects[store.Key(cpRef.SHA256)] = append([]byte(nil), checkpointBytes...) + w.objects[store.Key(sigRef.SHA256)] = append([]byte(nil), signatureBytes...) + position := state.CheckpointPosition{Sequence: sequence, Digest: cpRef.SHA256} + if previous != nil { + position.PreviousDigest = previous.Checkpoint.SHA256 + } + transition := "initial" + if sequence > 0 { + transition = "phase1-outbound-published" + } + verifier.byDigest[cpRef.SHA256] = Checkpoint{CeremonyID: ceremonyID, Position: position, Previous: previous, Transition: transition} + dir := t.TempDir() + cpPath, sigPath := filepath.Join(dir, "checkpoint.json"), filepath.Join(dir, "checkpoint.sig") + if err := os.WriteFile(cpPath, checkpointBytes, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sigPath, signatureBytes, 0o600); err != nil { + t.Fatal(err) + } + verifier.evidence = EvidenceVerification{CeremonyID: ceremonyID, Sequence: sequence, Digest: cpRef.SHA256, TransitionKind: transition, FullyVerified: true} + child, err := AuthenticateRootChild(verifier, cpRef, sigRef, dir, cpPath, sigPath) + if err != nil { + t.Fatal(err) + } + return cpRef, sigRef, child +} + +func digestOfTest(c string) string { return "sha256:" + string(makeHex(c, 64)) } +func makeHex(c string, n int) []byte { + result := make([]byte, n) + for i := range result { + result[i] = c[0] + } + return result +} diff --git a/internal/storagefirst/delivery.go b/internal/storagefirst/delivery.go new file mode 100644 index 0000000..fc65105 --- /dev/null +++ b/internal/storagefirst/delivery.go @@ -0,0 +1,472 @@ +package storagefirst + +// This is the transport-only lane for the revised workflow. It does not change +// the released envelope-based lane in submission.go and cannot authenticate or +// accept a ceremony contribution. Callers must verify payloads with proof-tool. + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/store" +) + +const deliverySchema = "relay-submission-transport-v1" +const maxDeliveryManifestBytes = 1 << 20 + +// DeliveryScope comes from the active protocol slot, not from the manifest. +// The provider prefix is derived here in Relay and never passed to proof-tool. +type DeliveryScope struct { + CeremonyID string + AttemptID string + Kind string +} + +func (s DeliveryScope) validate() error { + if !validDigest(s.CeremonyID) || !validAttempt(s.AttemptID) { + return errors.New("delivery requires an exact ceremony and allocated attempt") + } + if s.Kind != "receipt" && s.Kind != "candidate" && s.Kind != "enrollment" && s.Kind != "release" { + return errors.New("unsupported delivery kind") + } + return nil +} + +func (s DeliveryScope) Prefix() (string, error) { + if err := s.validate(); err != nil { + return "", err + } + return "submissions/" + strings.TrimPrefix(s.CeremonyID, "sha256:") + "/" + s.AttemptID, nil +} + +type deliveryManifest struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + AttemptID string `json:"attempt_id"` + Kind string `json:"kind"` + Files []deliveryFile `json:"files"` +} + +type deliveryFile struct { + Name string `json:"name"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +// DeliveryInventory is supplied by the workflow, never by uploaded metadata. +// Each value is an independently selected maximum byte count. The candidate +// contribution bound must come from the expected circuit/runtime, not a remote +// manifest's claimed size. This layer does not choose required ceremony records. +type DeliveryInventory map[string]int64 + +// FetchedDelivery is the exact transport package that FetchDeliveryVerified +// placed in its fresh private directory. It is transport evidence only: a +// caller must still ask proof-tool to authenticate the ceremony records. +// Manifest and Files contain no credentials and are ordered as the verified +// transport manifest was ordered. +type FetchedDelivery struct { + Dir string + Manifest state.ContentRef + Files []state.ContentRef +} + +func (i DeliveryInventory) validate() error { + if len(i) == 0 || len(i) > 2048 { + return errors.New("invalid delivery inventory count") + } + for name, limit := range i { + // A release package legitimately contains its signed manifest.json. It + // is stored below files/ and remains distinct from this transport + // layer's own prefix-level manifest.json. + if !validDeliveryName(name) || strings.ContainsAny(name, ":\x00\r\n\t") || limit <= 0 || limit > 16<<30 { + return errors.New("delivery inventory requires safe relative file names and positive bounded sizes") + } + for _, r := range name { + if r < 32 || r > 126 { + return errors.New("delivery file names must be printable ASCII") + } + } + } + return nil +} + +func validDeliveryName(name string) bool { + if name == "" || len(name) > 512 || strings.HasPrefix(name, "/") || strings.Contains(name, "\\") || filepath.Clean(name) != name { + return false + } + for _, component := range strings.Split(name, "/") { + if !validComponent(component) { + return false + } + } + return true +} + +func (i DeliveryInventory) validateKind(kind string) error { + if err := i.validate(); err != nil { + return err + } + if kind == "release" { + names := make([]string, 0, len(i)) + for name := range i { + names = append(names, name) + } + slices.Sort(names) + for index, name := range names { + if index > 0 && strings.HasPrefix(name, names[index-1]+"/") { + return errors.New("release delivery paths overlap as a file and directory") + } + } + return nil + } + want := []string{"receipt.json", "receipt.sig"} + if kind == "candidate" { + want = []string{"attestation.json", "attestation.sig", "contribution.bin", "erasure.json", "erasure.sig"} + } else if kind == "enrollment" { + want = []string{"enrollment.json", "disclosure.txt", "enrollment.sig"} + } else if kind != "receipt" { + return errors.New("unsupported delivery kind") + } + if len(i) != len(want) { + return errors.New("unexpected files for delivery kind") + } + for _, name := range want { + limit, exists := i[name] + if !exists { + return errors.New("missing required delivery file") + } + if strings.HasSuffix(name, ".sig") && limit > 4096 { + return errors.New("delivery signature bound exceeds 4096 bytes") + } + if strings.HasSuffix(name, ".json") && limit > 16<<20 { + return errors.New("delivery record bound exceeds 16 MiB") + } + } + return nil +} + +func decodeDelivery(raw []byte, scope DeliveryScope, inventory DeliveryInventory) (deliveryManifest, error) { + var m deliveryManifest + if err := scope.validate(); err != nil { + return m, err + } + if err := inventory.validateKind(scope.Kind); err != nil { + return m, err + } + if len(raw) > maxDeliveryManifestBytes { + return m, errors.New("delivery manifest exceeds size bound") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&m); err != nil { + return m, err + } + // Exact encoding rejects duplicate fields, trailing data and alternate JSON + // spellings. This is transport framing, not a new signed canonical format. + encoded, err := json.Marshal(m) + if err != nil || !bytes.Equal(raw, encoded) { + return m, errors.New("delivery manifest is not in its exact wire format") + } + if m.Schema != deliverySchema || m.CeremonyID != scope.CeremonyID || m.AttemptID != scope.AttemptID || m.Kind != scope.Kind { + return m, errors.New("delivery manifest does not match the allocated scope") + } + if len(m.Files) != len(inventory) { + return m, errors.New("delivery inventory mismatch") + } + previous := "" + for _, file := range m.Files { + limit, expected := inventory[file.Name] + if !expected || file.Name <= previous || file.Size <= 0 || file.Size > limit || !validDigest(file.SHA256) { + return m, errors.New("delivery contains unknown, duplicate, unordered or invalid files") + } + previous = file.Name + } + return m, nil +} + +// UploadDelivery uploads only the exact supplied inventory, staging verified +// private copies before any provider writes. Repeating identical bytes is safe; +// different existing bytes fail. A successful return means uploaded, not accepted. +func UploadDelivery(objects ImmutableStore, scope DeliveryScope, inventory DeliveryInventory, sources map[string]state.ContentRef, paths map[string]string, tempParent string) error { + if objects == nil { + return errors.New("delivery store required") + } + prefix, err := scope.Prefix() + if err != nil { + return err + } + if err := inventory.validateKind(scope.Kind); err != nil { + return err + } + if len(sources) != len(inventory) || len(paths) != len(inventory) { + return errors.New("delivery sources do not match inventory") + } + names := make([]string, 0, len(inventory)) + for name := range inventory { + names = append(names, name) + } + slices.Sort(names) + m := deliveryManifest{Schema: deliverySchema, CeremonyID: scope.CeremonyID, AttemptID: scope.AttemptID, Kind: scope.Kind} + for _, name := range names { + ref, ok := sources[name] + if !ok || paths[name] == "" || ref.Size <= 0 || ref.Size > inventory[name] || !validDigest(ref.SHA256) { + return errors.New("delivery source is missing or exceeds its independent bound") + } + m.Files = append(m.Files, deliveryFile{Name: name, SHA256: ref.SHA256, Size: ref.Size}) + } + raw, err := json.Marshal(m) + if err != nil { + return err + } + if _, err := decodeDelivery(raw, scope, inventory); err != nil { + return err + } + tmp, err := os.MkdirTemp(tempParent, "relay-delivery-upload-") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + for _, file := range m.Files { + if err := stageDeliveryFile(paths[file.Name], filepath.Join(tmp, file.Name), file); err != nil { + return err + } + } + // Keep transport framing outside the staged payload namespace. A signed + // release package legitimately has its own files/manifest.json. + manifestPath := filepath.Join(tmp, ".relay-transport-manifest") + if err := os.WriteFile(manifestPath, raw, 0600); err != nil { + return err + } + for _, file := range m.Files { + if err := putExactDelivery(objects, prefix+"/files/"+file.Name, filepath.Join(tmp, file.Name), file, tmp); err != nil { + return err + } + } + return putExactDelivery(objects, prefix+"/manifest.json", manifestPath, deliveryFile{Name: "manifest.json", SHA256: digestBytes(raw), Size: int64(len(raw))}, tmp) +} + +func putExactDelivery(objects ImmutableStore, key, local string, file deliveryFile, temp string) error { + _, err := objects.PutIfAbsent(key, local) + if err == nil { + return nil + } + if !errors.Is(err, store.ErrExists) { + return err + } + comparison, err := os.MkdirTemp(temp, "existing-") + if err != nil { + return err + } + defer os.RemoveAll(comparison) + existing := filepath.Join(comparison, "object") + version, err := objects.GetVersionedAtMost(key, existing, file.Size) + if err != nil { + return err + } + if version.Size != file.Size { + return errors.New("existing delivery size differs") + } + return verifyLocalRef(state.ContentRef{SHA256: file.SHA256, Size: file.Size}, existing) +} + +func stageDeliveryFile(source, destination string, expected deliveryFile) error { + info, err := os.Lstat(source) + if err != nil || !info.Mode().IsRegular() || info.Size() != expected.Size { + return errors.New("delivery source must be a regular file of the expected size") + } + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + opened, err := input.Stat() + if err != nil || !opened.Mode().IsRegular() || !os.SameFile(info, opened) || opened.Size() != expected.Size { + return errors.New("delivery source changed while opening") + } + if err := os.MkdirAll(filepath.Dir(destination), 0700); err != nil { + return err + } + output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + n, copyErr := io.Copy(output, io.LimitReader(input, expected.Size)) + closeErr := output.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + var extra [1]byte + more, readErr := input.Read(extra[:]) + if n != expected.Size || more != 0 || readErr != io.EOF { + return errors.New("delivery source size changed during staging") + } + return verifyLocalRef(state.ContentRef{SHA256: expected.SHA256, Size: expected.Size}, destination) +} + +// FetchDelivery returns a private staging directory only after every listed +// byte has been checked. The caller owns cleanup and must ask proof-tool to +// authenticate the records before importing or accepting them. No role folder +// or completed-operation marker is modified here. +func FetchDelivery(objects ObjectStore, scope DeliveryScope, inventory DeliveryInventory, tempParent string) (string, error) { + fetched, err := FetchDeliveryVerified(objects, scope, inventory, tempParent) + if err != nil { + return "", err + } + return fetched.Dir, nil +} + +// FetchDeliveryVerified returns both the fresh private staging directory and +// the exact manifest/file references that were checked before it is returned. +// This lets a caller retain a small private receipt and revalidate the bytes +// before a later consequential decision without retaining transport framing in +// a fixed protocol directory. +func FetchDeliveryVerified(objects ObjectStore, scope DeliveryScope, inventory DeliveryInventory, tempParent string) (result FetchedDelivery, err error) { + if objects == nil { + return result, errors.New("delivery store required") + } + prefix, err := scope.Prefix() + if err != nil { + return result, err + } + if err := inventory.validateKind(scope.Kind); err != nil { + return result, err + } + result.Dir, err = os.MkdirTemp(tempParent, "relay-delivery-received-") + if err != nil { + return result, err + } + defer func() { + if err != nil { + os.RemoveAll(result.Dir) + result.Dir = "" + } + }() + manifestPath := filepath.Join(result.Dir, "manifest.json") + if _, err = objects.GetVersionedAtMost(prefix+"/manifest.json", manifestPath, maxDeliveryManifestBytes); err != nil { + return result, err + } + manifest, err := os.Open(manifestPath) + if err != nil { + return result, err + } + raw, err := io.ReadAll(io.LimitReader(manifest, maxDeliveryManifestBytes+1)) + manifest.Close() + if err != nil { + return result, err + } + m, err := decodeDelivery(raw, scope, inventory) + if err != nil { + return result, err + } + result.Manifest = state.ContentRef{Name: "manifest.json", SHA256: digestBytes(raw), Size: int64(len(raw))} + // Transport framing must not be forwarded as candidate/evidence content. + if err = os.Remove(manifestPath); err != nil { + return result, err + } + for _, file := range m.Files { + local := filepath.Join(result.Dir, file.Name) + if err = os.MkdirAll(filepath.Dir(local), 0700); err != nil { + return result, err + } + version, fetchErr := objects.GetVersionedAtMost(prefix+"/files/"+file.Name, local, file.Size) + if fetchErr != nil { + return result, fetchErr + } + if version.Size != file.Size { + return result, errors.New("delivery download size differs") + } + if err = verifyLocalRef(state.ContentRef{SHA256: file.SHA256, Size: file.Size}, local); err != nil { + return result, fmt.Errorf("delivery payload: %w", err) + } + result.Files = append(result.Files, state.ContentRef{Name: file.Name, SHA256: file.SHA256, Size: file.Size}) + } + return result, nil +} + +// FetchReleaseDelivery discovers a release package inventory from the trusted +// delivery service's immutable manifest, then verifies every downloaded byte. +// The caller must still ask proof-tool to authenticate the closed package; the +// transport manifest is not release approval. +func FetchReleaseDelivery(objects ObjectStore, scope DeliveryScope, tempParent string) (dir string, inventory DeliveryInventory, err error) { + if objects == nil || scope.Kind != "release" { + return "", nil, errors.New("release delivery store and release scope required") + } + prefix, err := scope.Prefix() + if err != nil { + return "", nil, err + } + dir, err = os.MkdirTemp(tempParent, "relay-release-received-") + if err != nil { + return "", nil, err + } + defer func() { + if err != nil { + os.RemoveAll(dir) + dir = "" + } + }() + manifestPath := filepath.Join(dir, "manifest.json") + if _, err = objects.GetVersionedAtMost(prefix+"/manifest.json", manifestPath, maxDeliveryManifestBytes); err != nil { + return dir, nil, err + } + raw, err := os.ReadFile(manifestPath) + if err != nil { + return dir, nil, err + } + var wire deliveryManifest + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err = decoder.Decode(&wire); err != nil { + return dir, nil, err + } + exact, marshalErr := json.Marshal(wire) + if marshalErr != nil || !bytes.Equal(raw, exact) || wire.Schema != deliverySchema || wire.CeremonyID != scope.CeremonyID || wire.AttemptID != scope.AttemptID || wire.Kind != scope.Kind { + return dir, nil, errors.New("release delivery manifest is not exact or does not match its allocated scope") + } + inventory = DeliveryInventory{} + previous := "" + for _, file := range wire.Files { + if file.Name <= previous || file.Size <= 0 || file.Size > 16<<30 || !validDigest(file.SHA256) { + return dir, nil, errors.New("release delivery manifest has invalid, duplicate or unordered files") + } + previous = file.Name + inventory[file.Name] = file.Size + } + if err = inventory.validateKind(scope.Kind); err != nil { + return dir, nil, err + } + if _, err = decodeDelivery(raw, scope, inventory); err != nil { + return dir, nil, err + } + if err = os.Remove(manifestPath); err != nil { + return dir, nil, err + } + for _, file := range wire.Files { + local := filepath.Join(dir, filepath.FromSlash(file.Name)) + if err = os.MkdirAll(filepath.Dir(local), 0700); err != nil { + return dir, nil, err + } + version, fetchErr := objects.GetVersionedAtMost(prefix+"/files/"+file.Name, local, file.Size) + if fetchErr != nil { + return dir, nil, fetchErr + } + if version.Size != file.Size { + return dir, nil, errors.New("release delivery download size differs") + } + if err = verifyLocalRef(state.ContentRef{SHA256: file.SHA256, Size: file.Size}, local); err != nil { + return dir, nil, fmt.Errorf("release delivery payload: %w", err) + } + } + return dir, inventory, nil +} diff --git a/internal/storagefirst/delivery_test.go b/internal/storagefirst/delivery_test.go new file mode 100644 index 0000000..b78f2d8 --- /dev/null +++ b/internal/storagefirst/delivery_test.go @@ -0,0 +1,333 @@ +package storagefirst + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/store" +) + +type deliveryStore struct { + objects map[string][]byte + writes []string + failKey string + lostResponse bool +} + +func (s *deliveryStore) PutIfAbsent(key, path string) (store.ObjectVersion, error) { + if _, exists := s.objects[key]; exists { + return store.ObjectVersion{}, store.ErrExists + } + if key == s.failKey && !s.lostResponse { + return store.ObjectVersion{}, errors.New("interrupted upload") + } + raw, err := os.ReadFile(path) + if err != nil { + return store.ObjectVersion{}, err + } + s.objects[key] = raw + s.writes = append(s.writes, key) + if key == s.failKey { + return store.ObjectVersion{}, errors.New("response lost after write") + } + return store.ObjectVersion{Size: int64(len(raw))}, nil +} + +func (s *deliveryStore) GetVersionedAtMost(key, path string, maximum int64) (store.ObjectVersion, error) { + raw, exists := s.objects[key] + if !exists { + return store.ObjectVersion{}, os.ErrNotExist + } + if int64(len(raw)) > maximum { + return store.ObjectVersion{}, errors.New("object exceeds bound") + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return store.ObjectVersion{}, err + } + _, err = file.Write(raw) + closeErr := file.Close() + if err != nil { + return store.ObjectVersion{}, err + } + return store.ObjectVersion{Size: int64(len(raw))}, closeErr +} + +func deliveryFixture(t *testing.T) (*deliveryStore, DeliveryScope, DeliveryInventory, map[string]state.ContentRef, map[string]string) { + t.Helper() + s := &deliveryStore{objects: make(map[string][]byte)} + scope := DeliveryScope{CeremonyID: digestBytes([]byte("test ceremony")), AttemptID: strings.Repeat("a", 32), Kind: "receipt"} + inventory := DeliveryInventory{"receipt.json": 1024, "receipt.sig": 4096} + refs := make(map[string]state.ContentRef) + paths := make(map[string]string) + dir := t.TempDir() + for name := range inventory { + raw := []byte("synthetic bytes for " + name) + paths[name] = filepath.Join(dir, name) + if err := os.WriteFile(paths[name], raw, 0600); err != nil { + t.Fatal(err) + } + refs[name] = state.ContentRef{Name: "logical/" + name, SHA256: digestBytes(raw), Size: int64(len(raw))} + } + return s, scope, inventory, refs, paths +} + +func TestDeliveryManifestLastAndExactRetry(t *testing.T) { + s, scope, inventory, refs, paths := deliveryFixture(t) + prefix, _ := scope.Prefix() + if err := UploadDelivery(s, scope, inventory, refs, paths, t.TempDir()); err != nil { + t.Fatal(err) + } + if got := s.writes[len(s.writes)-1]; got != prefix+"/manifest.json" { + t.Fatalf("last write = %s", got) + } + if err := UploadDelivery(s, scope, inventory, refs, paths, t.TempDir()); err != nil { + t.Fatal(err) + } + if len(s.writes) != 3 { + t.Fatal("retry wrote duplicate objects") + } + dir, err := FetchDelivery(s, scope, inventory, t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != len(inventory) { + t.Fatalf("transport metadata leaked into payload directory: %v %v", entries, err) + } + for name, ref := range refs { + if err := verifyLocalRef(ref, filepath.Join(dir, name)); err != nil { + t.Fatal(err) + } + } + // Plain synthetic files pass transport checks: they are deliberately NOT + // treated as authenticated receipts or completed ceremony operations. +} + +func TestDeliveryInterruptedUploadAndLostResponse(t *testing.T) { + for _, lost := range []bool{false, true} { + for _, target := range []string{"files/receipt.sig", "manifest.json"} { + t.Run(target+map[bool]string{false: "/before", true: "/after"}[lost], func(t *testing.T) { + s, scope, inventory, refs, paths := deliveryFixture(t) + prefix, _ := scope.Prefix() + s.failKey, s.lostResponse = prefix+"/"+target, lost + if err := UploadDelivery(s, scope, inventory, refs, paths, t.TempDir()); err == nil { + t.Fatal("interruption hidden") + } + if target != "manifest.json" || !lost { + if _, exists := s.objects[prefix+"/manifest.json"]; exists { + t.Fatal("premature manifest") + } + } + s.failKey = "" + if err := UploadDelivery(s, scope, inventory, refs, paths, t.TempDir()); err != nil { + t.Fatal(err) + } + if len(s.writes) != 3 { + t.Fatal("retry did not preserve exact upload") + } + }) + } + } +} + +func TestDeliveryConflictingExistingPayloadNeverCompletes(t *testing.T) { + s, scope, inventory, refs, paths := deliveryFixture(t) + prefix, _ := scope.Prefix() + s.objects[prefix+"/files/receipt.json"] = []byte("different") + if err := UploadDelivery(s, scope, inventory, refs, paths, t.TempDir()); err == nil { + t.Fatal("conflict accepted") + } + if _, ok := s.objects[prefix+"/manifest.json"]; ok { + t.Fatal("manifest published on conflict") + } +} + +func TestDeliveryRejectsChangedSourceBeforeAnyUpload(t *testing.T) { + s, scope, inventory, refs, paths := deliveryFixture(t) + if err := os.WriteFile(paths["receipt.sig"], []byte("different"), 0600); err != nil { + t.Fatal(err) + } + if err := UploadDelivery(s, scope, inventory, refs, paths, t.TempDir()); err == nil || len(s.writes) != 0 { + t.Fatalf("err=%v writes=%v", err, s.writes) + } +} + +func TestEnrollmentDeliveryUsesExactThreePublicFiles(t *testing.T) { + scope := DeliveryScope{CeremonyID: digestOfTest("0"), AttemptID: strings.Repeat("a", 32), Kind: "enrollment"} + inventory := DeliveryInventory{"enrollment.json": 1024, "enrollment.sig": 4096, "disclosure.txt": 1024} + dir := t.TempDir() + paths, refs := map[string]string{}, map[string]state.ContentRef{} + for name := range inventory { + path := filepath.Join(dir, name) + body := []byte("public " + name) + if err := os.WriteFile(path, body, 0600); err != nil { + t.Fatal(err) + } + paths[name] = path + refs[name] = state.ContentRef{Name: name, SHA256: digestBytes(body), Size: int64(len(body))} + } + objects := &deliveryStore{objects: make(map[string][]byte)} + if err := UploadDelivery(objects, scope, inventory, refs, paths, dir); err != nil { + t.Fatal(err) + } + received, err := FetchDelivery(objects, scope, inventory, dir) + if err != nil { + t.Fatal(err) + } + for name, source := range paths { + want, _ := os.ReadFile(source) + got, err := os.ReadFile(filepath.Join(received, name)) + if err != nil || string(got) != string(want) { + t.Fatalf("%s differs after immutable delivery: %v", name, err) + } + } +} + +func TestReleaseDeliverySupportsAuthenticatedNestedPackage(t *testing.T) { + scope := DeliveryScope{CeremonyID: digestOfTest("0"), AttemptID: strings.Repeat("c", 32), Kind: "release"} + inventory := DeliveryInventory{"manifest.json": 1024, "manifest.sig": 4096, "operational/evidence-bundle.json": 4096} + dir := t.TempDir() + paths, refs := map[string]string{}, map[string]state.ContentRef{} + for name := range inventory { + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + body := []byte("signed release " + name) + if err := os.WriteFile(path, body, 0600); err != nil { + t.Fatal(err) + } + paths[name] = path + refs[name] = state.ContentRef{Name: name, SHA256: digestBytes(body), Size: int64(len(body))} + } + objects := &deliveryStore{objects: make(map[string][]byte)} + if err := UploadDelivery(objects, scope, inventory, refs, paths, dir); err != nil { + t.Fatal(err) + } + received, discovered, err := FetchReleaseDelivery(objects, scope, dir) + if err != nil { + t.Fatal(err) + } + if len(discovered) != len(inventory) { + t.Fatalf("discovered %d files, want %d", len(discovered), len(inventory)) + } + for name, ref := range refs { + if err := verifyLocalRef(ref, filepath.Join(received, filepath.FromSlash(name))); err != nil { + t.Fatal(err) + } + } + bad := DeliveryInventory{"file": 1, "file/child": 1} + if err := bad.validateKind("release"); err == nil { + t.Fatal("overlapping release paths accepted") + } +} + +func TestDeliveryRejectsManifestScopeAndInventoryChanges(t *testing.T) { + s, scope, inventory, refs, paths := deliveryFixture(t) + if err := UploadDelivery(s, scope, inventory, refs, paths, t.TempDir()); err != nil { + t.Fatal(err) + } + prefix, _ := scope.Prefix() + raw := s.objects[prefix+"/manifest.json"] + cases := map[string]func(*deliveryManifest){ + "ceremony": func(m *deliveryManifest) { m.CeremonyID = digestBytes([]byte("other")) }, + "attempt": func(m *deliveryManifest) { m.AttemptID = strings.Repeat("b", 32) }, + "kind": func(m *deliveryManifest) { m.Kind = "candidate" }, + "missing": func(m *deliveryManifest) { m.Files = m.Files[:1] }, + "duplicate": func(m *deliveryManifest) { m.Files[1] = m.Files[0] }, + "path": func(m *deliveryManifest) { m.Files[0].Name = "../receipt.json" }, + "large": func(m *deliveryManifest) { m.Files[0].Size = 1 << 60 }, + "zero": func(m *deliveryManifest) { m.Files[0].Size = 0 }, + "hash": func(m *deliveryManifest) { m.Files[0].SHA256 = "no" }, + "order": func(m *deliveryManifest) { m.Files[0], m.Files[1] = m.Files[1], m.Files[0] }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + var m deliveryManifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + mutate(&m) + bad, _ := json.Marshal(m) + if _, err := decodeDelivery(bad, scope, inventory); err == nil { + t.Fatal("invalid manifest accepted") + } + }) + } + for _, bad := range [][]byte{append(bytes.Clone(raw), []byte("{}")...), bytes.Replace(raw, []byte(`"schema":`), []byte(`"unexpected":1,"schema":`), 1), bytes.Replace(raw, []byte(`"schema":`), []byte(`"schema":"ignored","schema":`), 1)} { + if _, err := decodeDelivery(bad, scope, inventory); err == nil { + t.Fatal("ambiguous JSON accepted") + } + } +} + +func TestDeliveryIncompleteFetchLeavesNoReturnedFolder(t *testing.T) { + s, scope, inventory, refs, paths := deliveryFixture(t) + if err := UploadDelivery(s, scope, inventory, refs, paths, t.TempDir()); err != nil { + t.Fatal(err) + } + prefix, _ := scope.Prefix() + delete(s.objects, prefix+"/files/receipt.sig") + parent := t.TempDir() + if dir, err := FetchDelivery(s, scope, inventory, parent); err == nil || dir != "" { + t.Fatalf("dir=%q err=%v", dir, err) + } + entries, err := os.ReadDir(parent) + if err != nil || len(entries) != 0 { + t.Fatalf("partial files retained: %v %v", entries, err) + } +} + +func TestDeliveryRejectsPrivateAndIncompleteInventories(t *testing.T) { + for _, inventory := range []DeliveryInventory{ + {"signing.hex": 1024, "receipt.sig": 4096}, + {"receipt.json": 1024, "receipt.sig": 8192}, + {"receipt.json": 32 << 20, "receipt.sig": 4096}, + {"receipt.json": 1024}, + } { + if err := inventory.validateKind("receipt"); err == nil { + t.Fatalf("unsafe inventory accepted: %v", inventory) + } + } + i := DeliveryInventory{"attestation.json": 1024, "attestation.sig": 4096, "erasure.json": 1024, "erasure.sig": 4096, "contribution.bin": 1 << 30} + if err := i.validateKind("candidate"); err != nil { + t.Fatal(err) + } + i["return-handoff.sig"] = 4096 + if err := i.validateKind("candidate"); err == nil { + t.Fatal("unpaired return evidence accepted") + } + i["return-handoff.json"] = 1024 + if err := i.validateKind("candidate"); err == nil { + t.Fatal("obsolete return-custody files accepted") + } +} + +func TestDeliveryRedeliveryUsesNewAttemptWithoutChangingPayloads(t *testing.T) { + s, scope, inventory, refs, paths := deliveryFixture(t) + if err := UploadDelivery(s, scope, inventory, refs, paths, t.TempDir()); err != nil { + t.Fatal(err) + } + first, _ := scope.Prefix() + scope.AttemptID = strings.Repeat("b", 32) + if err := UploadDelivery(s, scope, inventory, refs, paths, t.TempDir()); err != nil { + t.Fatal(err) + } + second, _ := scope.Prefix() + for name := range inventory { + if !bytes.Equal(s.objects[first+"/files/"+name], s.objects[second+"/files/"+name]) { + t.Fatal("redelivery changed payload") + } + } + // Allocation/retirement and rejected candidate policy are protocol checks, + // outside this transport. This proves only that no extra participant + // signature is required to carry identical records to another attempt. +} diff --git a/internal/storagefirst/grant.go b/internal/storagefirst/grant.go new file mode 100644 index 0000000..77ca4eb --- /dev/null +++ b/internal/storagefirst/grant.go @@ -0,0 +1,176 @@ +package storagefirst + +import ( + "errors" + "strings" + "time" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/transcript" +) + +// ValidateGrantV4At binds temporary upload credentials to the exact active +// candidate allocation in a fully authenticated V4 snapshot. The grant's own +// fields are never sufficient authorization. +func ValidateGrantV4At(snapshot SnapshotV4, protocol transcript.DefinitionProtocol, identity string, grant access.StorageFirstGrant, destination GrantDestination, now time.Time) error { + if err := grant.CheckUnexpired(now); err != nil { + return err + } + if grant.CeremonyID != protocol.Definition.CeremonyID || grant.IdentityID != identity || grant.SubmissionKind != access.SubmissionKindCandidate { + return errors.New("grant belongs to another ceremony, participant or submission kind") + } + if grant.Provider != destination.Provider || grant.Endpoint != destination.Endpoint || grant.Region != destination.Region || grant.InboxBucket != destination.InboxBucket { + return errors.New("grant storage destination differs from the verified local storage setup") + } + view, err := snapshot.TurnV4(protocol, grant.Phase, identity) + if err != nil { + return err + } + if view.Stage != TurnCandidateV4 || view.CandidateAttempt == nil || view.Scope.Index != grant.Index || view.CandidateAttempt.AttemptID != grant.AttemptID { + return errors.New("grant does not match the active authenticated candidate allocation") + } + var allocation *transcript.CandidateAllocationV4 + if view.Commitment != nil { + for n := range view.Commitment.Allocations { + if view.Commitment.Allocations[n].AttemptID == grant.AttemptID { + allocation = &view.Commitment.Allocations[n] + break + } + } + } + if allocation == nil || grant.CheckpointDigest != allocation.Checkpoint.Record.Digest.SHA256 { + return errors.New("grant checkpoint differs from the authenticated allocation checkpoint") + } + prefix, err := (DeliveryScope{CeremonyID: grant.CeremonyID, AttemptID: grant.AttemptID, Kind: access.SubmissionKindCandidate}).Prefix() + if err != nil { + return err + } + if grant.Prefix != prefix+"/" || grant.ManifestKey != prefix+"/manifest.json" { + return errors.New("grant object scope differs from the authenticated candidate attempt") + } + return nil +} + +// ValidateEnrollmentGrantV4At binds a bootstrap upload credential to an exact +// role assignment in the signed definition and to a checkpoint in the current +// authenticated ancestry. Enrollment bytes remain untrusted until proof-tool +// verifies them and the coordinator records them in a descendant checkpoint. +func ValidateEnrollmentGrantV4At(snapshot SnapshotV4, protocol transcript.DefinitionProtocol, identity, role string, roleIndex int, grant access.StorageFirstGrant, destination GrantDestination, now time.Time) error { + if err := grant.CheckUnexpired(now); err != nil { + return err + } + if grant.CeremonyID != protocol.Definition.CeremonyID || grant.IdentityID != identity || grant.SubmissionKind != access.SubmissionKindEnrollment || grant.Phase != "setup" || int(grant.Index) != roleIndex { + return errors.New("grant belongs to another ceremony, assignment or submission kind") + } + if grant.Provider != destination.Provider || grant.Endpoint != destination.Endpoint || grant.Region != destination.Region || grant.InboxBucket != destination.InboxBucket { + return errors.New("grant storage destination differs from the verified local storage setup") + } + journey, err := protocol.Definition.RequireJourney() + if err != nil { + return err + } + assigned := false + for _, expected := range journey.RequiredEnrollments { + if expected.Identity.ID == identity && expected.Role == role && expected.RoleIndex == roleIndex { + assigned = true + } + } + if !assigned { + return errors.New("enrollment grant does not match a signed role assignment") + } + if !snapshot.ContainsCheckpointDigest(grant.CheckpointDigest) { + return errors.New("enrollment grant checkpoint is not in the authenticated current ancestry") + } + prefix, err := (DeliveryScope{CeremonyID: grant.CeremonyID, AttemptID: grant.AttemptID, Kind: access.SubmissionKindEnrollment}).Prefix() + if err != nil { + return err + } + if grant.Prefix != prefix+"/" || grant.ManifestKey != prefix+"/manifest.json" { + return errors.New("grant object scope differs from the enrollment attempt") + } + return nil +} + +// ValidateReleaseGrantV4At binds one upload credential to the exact frozen +// release-review checkpoint and the authenticated release-signer assignment. +func ValidateReleaseGrantV4At(snapshot SnapshotV4, protocol transcript.DefinitionProtocol, identity string, grant access.StorageFirstGrant, destination GrantDestination, now time.Time) error { + if err := grant.CheckUnexpired(now); err != nil { + return err + } + if grant.CeremonyID != protocol.Definition.CeremonyID || grant.IdentityID != identity || grant.SubmissionKind != access.SubmissionKindRelease || grant.Phase != "release" || grant.Index != 1 { + return errors.New("grant belongs to another ceremony, release signer or submission kind") + } + if grant.Provider != destination.Provider || grant.Endpoint != destination.Endpoint || grant.Region != destination.Region || grant.InboxBucket != destination.InboxBucket { + return errors.New("grant storage destination differs from the verified local storage setup") + } + state, err := snapshot.State() + if err != nil { + return err + } + if state.Progress.ReleaseReview == nil || state.Progress.FinalRelease != nil || grant.CheckpointDigest != snapshot.Head().Record.Digest.SHA256 { + return errors.New("release grant does not match the exact current frozen review checkpoint") + } + journey, err := protocol.Definition.RequireJourney() + if err != nil { + return err + } + assigned := false + for _, expected := range journey.RequiredEnrollments { + if expected.Role == "release-signer" && expected.Identity.ID == identity { + assigned = true + } + } + if !assigned { + return errors.New("release grant does not match the signed release-signer assignment") + } + prefix, err := (DeliveryScope{CeremonyID: grant.CeremonyID, AttemptID: grant.AttemptID, Kind: access.SubmissionKindRelease}).Prefix() + if err != nil { + return err + } + if grant.Prefix != prefix+"/" || grant.ManifestKey != prefix+"/manifest.json" { + return errors.New("grant object scope differs from the release attempt") + } + return nil +} + +type GrantDestination struct { + Provider string + Endpoint string + Region string + InboxBucket string +} + +// ValidateGrant binds a decoded temporary credential to one exact allocated +// slot in a fully evidence-verified checkpoint. Self-consistent grant JSON is +// never enough to authorize an upload. +func ValidateGrantAt(checkpoint Checkpoint, grant access.StorageFirstGrant, destination GrantDestination, now time.Time) error { + if !checkpoint.authenticatedEvidence { + return errors.New("grant validation requires a fully evidence-verified checkpoint") + } + if err := grant.CheckUnexpired(now); err != nil { + return err + } + if grant.CeremonyID != checkpoint.CeremonyID { + return errors.New("grant belongs to another ceremony") + } + if grant.Provider != destination.Provider || grant.Endpoint != destination.Endpoint || grant.Region != destination.Region || grant.InboxBucket != destination.InboxBucket { + return errors.New("grant storage destination differs from the verified local storage setup") + } + var slot *Slot + for i := range checkpoint.Slots { + candidate := &checkpoint.Slots[i] + if candidate.Kind == grant.SubmissionKind && candidate.Phase == grant.Phase && candidate.Index == int(grant.Index) && + candidate.IdentityID == grant.IdentityID && candidate.AttemptID == grant.AttemptID { + slot = candidate + break + } + } + if slot == nil || slot.Status != "allocated" { + return errors.New("grant does not match an allocated submission slot") + } + if grant.CheckpointDigest != checkpoint.Position.Digest || grant.ManifestKey != slot.ManifestKey || + grant.Prefix != strings.TrimSuffix(slot.ManifestKey, "manifest.json") { + return errors.New("grant checkpoint or object scope differs from the authenticated submission slot") + } + return nil +} diff --git a/internal/storagefirst/grant_test.go b/internal/storagefirst/grant_test.go new file mode 100644 index 0000000..6197edd --- /dev/null +++ b/internal/storagefirst/grant_test.go @@ -0,0 +1,176 @@ +package storagefirst + +import ( + "strings" + "testing" + "time" + + "github.com/zksecurity/relay/internal/access" + "github.com/zksecurity/relay/internal/transcript" +) + +func TestValidateGrantV4BindsAuthenticatedAllocation(t *testing.T) { + _, protocol, c, commitments, enrollments, view := enrolledTurnV4(t, "phase1") + attempt := strings.Repeat("ab", 16) + commitments.Turns = []transcript.TurnCommitmentV4{{Scope: view.Scope, Allocations: []transcript.CandidateAllocationV4{{CheckpointSequence: 1, Checkpoint: allocationPairV4(1), AttemptID: attempt, AllocatedAt: "2026-09-16T00:00:00Z"}}}} + c.Deliveries = []transcript.DeliverySlotV4{{Scope: view.Scope, Kind: access.SubmissionKindCandidate, AttemptID: attempt, Status: "allocated"}} + snapshot := encodeTurnFixtureV4(t, c, commitments, enrollments) + snapshot.head = transcript.SignedArtifactRefs{Record: transcript.ArtifactRef{Name: "checkpoints/0001.json", Digest: transcript.Digest{SHA256: digestOfTest("1"), Size: 1}}, Signature: transcript.ArtifactRef{Name: "checkpoints/0001.sig", Digest: transcript.Digest{SHA256: digestOfTest("2"), Size: 1}}} + prefix, err := (DeliveryScope{CeremonyID: protocol.Definition.CeremonyID, AttemptID: attempt, Kind: access.SubmissionKindCandidate}).Prefix() + if err != nil { + t.Fatal(err) + } + grant := access.StorageFirstGrant{Schema: access.GrantSchemaV2, Provider: "r2", CeremonyID: protocol.Definition.CeremonyID, GrantRequestID: strings.Repeat("e", 32), CheckpointDigest: allocationPairV4(1).Record.Digest.SHA256, SubmissionKind: access.SubmissionKindCandidate, Phase: "phase1", Index: view.Scope.Index, IdentityID: view.Scope.ParticipantID, AttemptID: attempt, Endpoint: "https://account.r2.cloudflarestorage.com", Region: "auto", InboxBucket: "inbox", Prefix: prefix + "/", ManifestKey: prefix + "/manifest.json", IssuedAt: "2026-09-16T00:00:00Z", ExpiresAt: "2026-09-16T01:00:00Z", Credentials: access.SessionCredentials{AccessKeyID: "id", SecretAccessKey: "secret", SessionToken: "token"}} + destination := GrantDestination{Provider: grant.Provider, Endpoint: grant.Endpoint, Region: grant.Region, InboxBucket: grant.InboxBucket} + now := time.Date(2026, 9, 16, 0, 30, 0, 0, time.UTC) + if err := ValidateGrantV4At(snapshot, protocol, view.Scope.ParticipantID, grant, destination, now); err != nil { + t.Fatal(err) + } + for name, mutate := range map[string]func(*access.StorageFirstGrant){ + "checkpoint": func(g *access.StorageFirstGrant) { g.CheckpointDigest = digestOfTest("f") }, + "identity": func(g *access.StorageFirstGrant) { g.IdentityID = "p2" }, + "attempt": func(g *access.StorageFirstGrant) { g.AttemptID = strings.Repeat("f", 32) }, + "phase": func(g *access.StorageFirstGrant) { g.Phase = "phase2" }, + "index": func(g *access.StorageFirstGrant) { g.Index++ }, + "prefix": func(g *access.StorageFirstGrant) { g.Prefix = "submissions/other/" }, + } { + t.Run(name, func(t *testing.T) { + changed := grant + mutate(&changed) + if err := ValidateGrantV4At(snapshot, protocol, view.Scope.ParticipantID, changed, destination, now); err == nil { + t.Fatal("wrong-scope V4 grant accepted") + } + }) + } +} + +func TestValidateEnrollmentGrantV4BindsAssignmentAndAncestry(t *testing.T) { + snapshot, protocol, _, _, _, view := enrolledTurnV4(t, "phase1") + checkpoint := digestOfTest("9") + snapshot.history = []string{checkpoint} + attempt := strings.Repeat("ab", 16) + prefix, err := (DeliveryScope{CeremonyID: protocol.Definition.CeremonyID, AttemptID: attempt, Kind: access.SubmissionKindEnrollment}).Prefix() + if err != nil { + t.Fatal(err) + } + grant := access.StorageFirstGrant{Schema: access.GrantSchemaV2, Provider: "r2", CeremonyID: protocol.Definition.CeremonyID, GrantRequestID: strings.Repeat("e", 32), CheckpointDigest: checkpoint, SubmissionKind: access.SubmissionKindEnrollment, Phase: "setup", Index: 1, IdentityID: view.Scope.ParticipantID, AttemptID: attempt, Endpoint: "https://account.r2.cloudflarestorage.com", Region: "auto", InboxBucket: "inbox", Prefix: prefix + "/", ManifestKey: prefix + "/manifest.json", IssuedAt: "2026-09-16T00:00:00Z", ExpiresAt: "2026-09-16T01:00:00Z", Credentials: access.SessionCredentials{AccessKeyID: "id", SecretAccessKey: "secret", SessionToken: "token"}} + destination := GrantDestination{Provider: grant.Provider, Endpoint: grant.Endpoint, Region: grant.Region, InboxBucket: grant.InboxBucket} + now := time.Date(2026, 9, 16, 0, 30, 0, 0, time.UTC) + if err := ValidateEnrollmentGrantV4At(snapshot, protocol, view.Scope.ParticipantID, "participant", 1, grant, destination, now); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*access.StorageFirstGrant){ + func(g *access.StorageFirstGrant) { g.CheckpointDigest = digestOfTest("8") }, + func(g *access.StorageFirstGrant) { g.IdentityID = "another" }, + func(g *access.StorageFirstGrant) { g.Index = 2 }, + func(g *access.StorageFirstGrant) { g.SubmissionKind = access.SubmissionKindCandidate }, + } { + changed := grant + mutate(&changed) + if err := ValidateEnrollmentGrantV4At(snapshot, protocol, view.Scope.ParticipantID, "participant", 1, changed, destination, now); err == nil { + t.Fatal("wrong enrollment grant accepted") + } + } +} + +func TestValidateReleaseGrantV4BindsFrozenReviewAndSigner(t *testing.T) { + snapshot, protocol, c, index, enrollments := turnFixtureV4(t, "phase2") + head := allocationPairV4(12) + snapshot.head = head + c.Progress.ReleaseReview = &head + snapshot = encodeTurnFixtureV4(t, c, index, enrollments) + snapshot.head = head + attempt := strings.Repeat("cd", 16) + prefix, err := (DeliveryScope{CeremonyID: protocol.Definition.CeremonyID, AttemptID: attempt, Kind: access.SubmissionKindRelease}).Prefix() + if err != nil { + t.Fatal(err) + } + grant := access.StorageFirstGrant{Schema: access.GrantSchemaV2, Provider: "r2", CeremonyID: protocol.Definition.CeremonyID, GrantRequestID: strings.Repeat("e", 32), CheckpointDigest: head.Record.Digest.SHA256, SubmissionKind: access.SubmissionKindRelease, Phase: "release", Index: 1, IdentityID: "signer", AttemptID: attempt, Endpoint: "https://account.r2.cloudflarestorage.com", Region: "auto", InboxBucket: "inbox", Prefix: prefix + "/", ManifestKey: prefix + "/manifest.json", IssuedAt: "2026-09-16T00:00:00Z", ExpiresAt: "2026-09-16T01:00:00Z", Credentials: access.SessionCredentials{AccessKeyID: "id", SecretAccessKey: "secret", SessionToken: "token"}} + destination := GrantDestination{Provider: grant.Provider, Endpoint: grant.Endpoint, Region: grant.Region, InboxBucket: grant.InboxBucket} + now := time.Date(2026, 9, 16, 0, 30, 0, 0, time.UTC) + if err := ValidateReleaseGrantV4At(snapshot, protocol, "signer", grant, destination, now); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*access.StorageFirstGrant){ + func(g *access.StorageFirstGrant) { g.CheckpointDigest = digestOfTest("wrong") }, + func(g *access.StorageFirstGrant) { g.IdentityID = "coord" }, + func(g *access.StorageFirstGrant) { g.SubmissionKind = access.SubmissionKindCandidate }, + func(g *access.StorageFirstGrant) { g.Phase = "phase2" }, + } { + changed := grant + mutate(&changed) + if err := ValidateReleaseGrantV4At(snapshot, protocol, "signer", changed, destination, now); err == nil { + t.Fatal("wrong release grant accepted") + } + } + c.Progress.FinalRelease = &head + completed := encodeTurnFixtureV4(t, c, index, enrollments) + completed.head = head + if err := ValidateReleaseGrantV4At(completed, protocol, "signer", grant, destination, now); err == nil { + t.Fatal("release grant remained valid after final release") + } +} + +func storageFirstBoundGrant(cp Checkpoint, slot Slot) access.StorageFirstGrant { + prefix := strings.TrimSuffix(slot.ManifestKey, "manifest.json") + return access.StorageFirstGrant{ + Schema: access.GrantSchemaV2, Provider: "r2", CeremonyID: digestOfTest("0"), GrantRequestID: strings.Repeat("e", 32), + CheckpointDigest: cp.Position.Digest, SubmissionKind: slot.Kind, Phase: slot.Phase, Index: uint8(slot.Index), IdentityID: slot.IdentityID, + AttemptID: slot.AttemptID, Endpoint: "https://account.r2.cloudflarestorage.com", Region: "auto", InboxBucket: "inbox", + Prefix: prefix, ManifestKey: slot.ManifestKey, IssuedAt: "2026-09-15T01:00:00Z", ExpiresAt: "2026-09-15T02:00:00Z", + Credentials: access.SessionCredentials{AccessKeyID: "id", SecretAccessKey: "secret", SessionToken: "token"}, + } +} + +func TestValidateGrantBindsExactAuthenticatedSlot(t *testing.T) { + cp := actionCheckpoint(2, "phase1-receipt-accepted", "participant-1") + cp.CeremonyID = digestOfTest("0") + slot := cp.Slots[0] + grant := storageFirstBoundGrant(cp, slot) + destination := GrantDestination{Provider: grant.Provider, Endpoint: grant.Endpoint, Region: grant.Region, InboxBucket: grant.InboxBucket} + now := time.Date(2026, 9, 15, 1, 30, 0, 0, time.UTC) + if err := ValidateGrantAt(cp, grant, destination, now); err != nil { + t.Fatal(err) + } + for name, mutate := range map[string]func(*access.StorageFirstGrant){ + "checkpoint": func(g *access.StorageFirstGrant) { g.CheckpointDigest = digestOfTest("f") }, + "identity": func(g *access.StorageFirstGrant) { g.IdentityID = "participant-2" }, + "kind": func(g *access.StorageFirstGrant) { g.SubmissionKind = "receipt" }, + "attempt": func(g *access.StorageFirstGrant) { g.AttemptID = strings.Repeat("f", 32) }, + "ceremony": func(g *access.StorageFirstGrant) { g.CeremonyID = digestOfTest("f") }, + "other safe prefix": func(g *access.StorageFirstGrant) { + g.Prefix = "submissions/other/" + g.ManifestKey = g.Prefix + "manifest.json" + }, + } { + t.Run(name, func(t *testing.T) { + changed := grant + mutate(&changed) + if err := ValidateGrantAt(cp, changed, destination, now); err == nil { + t.Fatal("wrong-scope grant accepted") + } + }) + } + cp.authenticatedEvidence = false + if err := ValidateGrantAt(cp, grant, destination, now); err == nil { + t.Fatal("shallow checkpoint accepted") + } + cp.authenticatedEvidence = true + if err := ValidateGrantAt(cp, grant, destination, time.Date(2026, 9, 15, 2, 0, 0, 0, time.UTC)); err == nil { + t.Fatal("expired exact-slot grant accepted") + } + wrongDestination := destination + wrongDestination.InboxBucket = "another-inbox" + if err := ValidateGrantAt(cp, grant, wrongDestination, now); err == nil { + t.Fatal("grant for another storage destination accepted") + } +} + +func TestStorageFirstGrantRejectsExcessiveLifetime(t *testing.T) { + cp := actionCheckpoint(2, "phase1-receipt-accepted", "participant-1") + grant := storageFirstBoundGrant(cp, cp.Slots[0]) + grant.ExpiresAt = time.Date(2026, 9, 15, 2, 0, 1, 0, time.UTC).Format(time.RFC3339) + if err := grant.Validate(); err == nil { + t.Fatal("overlong temporary credential accepted") + } +} diff --git a/internal/storagefirst/immutable.go b/internal/storagefirst/immutable.go new file mode 100644 index 0000000..6b31b16 --- /dev/null +++ b/internal/storagefirst/immutable.go @@ -0,0 +1,97 @@ +package storagefirst + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/store" +) + +type ImmutableStore interface { + GetVersionedAtMost(key, localPath string, maximum int64) (store.ObjectVersion, error) + PutIfAbsent(key, localPath string) (store.ObjectVersion, error) +} + +// PublishImmutable creates a content-addressed object. If the key already +// exists, it downloads and hashes those bytes before treating the operation as +// an idempotent retry. Mere existence is not proof of identical content. +func PublishImmutable(objects ImmutableStore, ref state.ContentRef, localPath, tempParent string) error { + if objects == nil { + return errors.New("immutable object store is required") + } + if err := verifyLocalRef(ref, localPath); err != nil { + return err + } + // The provider reopens its source path. Stage an independently verified + // private copy so a replacement of the caller's file cannot change what is + // uploaded after verification. + temp, err := os.MkdirTemp(tempParent, "relay-immutable-upload-") + if err != nil { + return err + } + defer os.RemoveAll(temp) + staged := filepath.Join(temp, "payload") + if err := stageDeliveryFile(localPath, staged, deliveryFile{SHA256: ref.SHA256, Size: ref.Size}); err != nil { + return err + } + _, err = objects.PutIfAbsent(store.Key(ref.SHA256), staged) + if err == nil { + return nil + } + if !errors.Is(err, store.ErrExists) { + return err + } + return fetchExact(objects, ref, filepath.Join(temp, "object")) +} + +func verifyLocalRef(ref state.ContentRef, localPath string) error { + if ref.Size <= 0 || !validDigest(ref.SHA256) { + return errors.New("immutable reference requires a positive size and valid SHA-256") + } + info, err := os.Lstat(localPath) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("immutable upload source must be a regular non-symlink file") + } + if info.Size() != ref.Size { + return fmt.Errorf("immutable upload source size %d, want %d", info.Size(), ref.Size) + } + file, err := os.Open(localPath) + if err != nil { + return err + } + defer file.Close() + opened, err := file.Stat() + if err != nil || !opened.Mode().IsRegular() || !os.SameFile(info, opened) || opened.Size() != ref.Size { + return errors.New("immutable source changed while opening") + } + hash := sha256.New() + // Hash only the declared bytes, then check for growth without size+1 overflow. + n, err := io.Copy(hash, io.LimitReader(file, ref.Size)) + if err != nil { + return err + } + var extra [1]byte + more, readErr := file.Read(extra[:]) + if n != ref.Size || more != 0 || readErr != io.EOF { + return errors.New("immutable source size changed while hashing") + } + after, err := file.Stat() + if err != nil || after.Size() != opened.Size() || !after.ModTime().Equal(opened.ModTime()) { + return errors.New("immutable source changed while hashing") + } + if "sha256:"+hex.EncodeToString(hash.Sum(nil)) != ref.SHA256 { + return errors.New("immutable upload source digest does not match its reference") + } + return nil +} + +func digestBytes(raw []byte) string { + digest := sha256.Sum256(raw) + return "sha256:" + hex.EncodeToString(digest[:]) +} diff --git a/internal/storagefirst/immutable_test.go b/internal/storagefirst/immutable_test.go new file mode 100644 index 0000000..8a7f61f --- /dev/null +++ b/internal/storagefirst/immutable_test.go @@ -0,0 +1,156 @@ +package storagefirst + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/store" +) + +func TestVerifyLocalRefStreamsLargeArtifact(t *testing.T) { + path := filepath.Join(t.TempDir(), "contribution.bin") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + const size = 32 << 20 + if err := file.Truncate(size); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + file, err = os.Open(path) + if err != nil { + t.Fatal(err) + } + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + t.Fatal(err) + } + file.Close() + ref := state.ContentRef{Name: "contribution.bin", SHA256: "sha256:" + hex.EncodeToString(hash.Sum(nil)), Size: size} + if err := verifyLocalRef(ref, path); err != nil { + t.Fatal(err) + } + for _, delta := range []int64{-1, 1} { + changed := ref + changed.Size += delta + if err := verifyLocalRef(changed, path); err == nil { + t.Fatal("wrong size accepted") + } + } + ref.SHA256 = digestBytes([]byte("wrong")) + if err := verifyLocalRef(ref, path); err == nil { + t.Fatal("wrong digest accepted") + } +} + +func TestVerifyLocalRefRejectsSymlinkAndInvalidReference(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "payload") + if err := os.WriteFile(path, []byte("ok"), 0600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(path, link); err != nil { + t.Fatal(err) + } + ref := state.ContentRef{Name: "payload", SHA256: digestBytes([]byte("ok")), Size: 2} + if err := verifyLocalRef(ref, link); err == nil { + t.Fatal("symlink accepted") + } + ref.SHA256 = "invalid" + if err := verifyLocalRef(ref, path); err == nil { + t.Fatal("invalid reference accepted") + } +} + +type immutableFake struct { + existing []byte + putErr error + puts int +} + +type replacingUploadStore struct { + original string + uploaded []byte +} + +func (s *replacingUploadStore) PutIfAbsent(_ string, source string) (store.ObjectVersion, error) { + if err := os.WriteFile(s.original, []byte("changed"), 0600); err != nil { + return store.ObjectVersion{}, err + } + var err error + s.uploaded, err = os.ReadFile(source) + return store.ObjectVersion{}, err +} + +func (s *replacingUploadStore) GetVersionedAtMost(_, _ string, _ int64) (store.ObjectVersion, error) { + return store.ObjectVersion{}, errors.New("unexpected fetch") +} + +func TestPublishImmutableStagesVerifiedBytesBeforeProviderReopensSource(t *testing.T) { + path := filepath.Join(t.TempDir(), "original") + raw := []byte("correct") + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + s := &replacingUploadStore{original: path} + ref := state.ContentRef{Name: "payload", SHA256: digestBytes(raw), Size: int64(len(raw))} + if err := PublishImmutable(s, ref, path, t.TempDir()); err != nil { + t.Fatal(err) + } + if string(s.uploaded) != string(raw) { + t.Fatalf("uploaded replaced source: %q", s.uploaded) + } +} + +func (f *immutableFake) PutIfAbsent(_ string, _ string) (store.ObjectVersion, error) { + f.puts++ + return store.ObjectVersion{}, f.putErr +} +func (f *immutableFake) GetVersionedAtMost(_ string, local string, maximum int64) (store.ObjectVersion, error) { + if int64(len(f.existing)) > maximum { + return store.ObjectVersion{}, errors.New("too large") + } + if err := os.WriteFile(local, f.existing, 0o600); err != nil { + return store.ObjectVersion{}, err + } + return store.ObjectVersion{ETag: "existing", Size: int64(len(f.existing))}, nil +} + +func TestPublishImmutableVerifiesExistingBytesOnRetry(t *testing.T) { + raw := []byte("checkpoint") + path := filepath.Join(t.TempDir(), "checkpoint.json") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + ref := state.ContentRef{Name: "checkpoints/0.json", SHA256: digestBytes(raw), Size: int64(len(raw))} + storeFake := &immutableFake{existing: raw, putErr: store.ErrExists} + if err := PublishImmutable(storeFake, ref, path, t.TempDir()); err != nil { + t.Fatal(err) + } + storeFake.existing = []byte("other bytes") + if err := PublishImmutable(storeFake, ref, path, t.TempDir()); err == nil { + t.Fatal("pre-existing different bytes accepted") + } +} + +func TestPublishImmutableRejectsWrongLocalBytesBeforeUpload(t *testing.T) { + path := filepath.Join(t.TempDir(), "checkpoint.json") + if err := os.WriteFile(path, []byte("wrong"), 0o600); err != nil { + t.Fatal(err) + } + storeFake := &immutableFake{} + err := PublishImmutable(storeFake, state.ContentRef{Name: "x", SHA256: digestBytes([]byte("right")), Size: 5}, path, t.TempDir()) + if err == nil || storeFake.puts != 0 { + t.Fatalf("err=%v puts=%d", err, storeFake.puts) + } +} diff --git a/internal/storagefirst/journey_test.go b/internal/storagefirst/journey_test.go new file mode 100644 index 0000000..795ac8e --- /dev/null +++ b/internal/storagefirst/journey_test.go @@ -0,0 +1,38 @@ +package storagefirst + +import "testing" + +// Each case reconstructs durable facts as a restarted process would. No prior +// evaluator state or unscoped completion flag crosses a boundary. +func TestOnePhase1TurnRecommendationsFromPersistableExactFacts(t *testing.T) { + participantID := "participant-1" + cp1 := actionCheckpoint(1, "phase1-outbound-published", participantID) + cp2 := actionCheckpoint(2, "phase1-receipt-accepted", participantID) + download := operation(OperationOutboundDownloaded, cp1, cp1.Slots[0], digestOfTest("4")) + receipt := operation(OperationReceiptUploaded, cp1, cp1.Slots[0], digestOfTest("5")) + grant := operation(OperationCandidateGrant, cp2, cp2.Slots[0], digestOfTest("6")) + computed := operation(OperationCandidateComputed, cp2, cp2.Slots[0], digestOfTest("7")) + uploaded := operation(OperationCandidateUploaded, cp2, cp2.Slots[0], computed.ArtifactDigest) + views := []struct { + name string + cp Checkpoint + facts []OperationFact + want Action + ready bool + }{ + {"download exact outbound", cp1, nil, ActionDownloadOutbound, true}, + {"upload exact receipt", cp1, []OperationFact{download}, ActionUploadReceipt, true}, + {"wait after exact receipt upload", cp1, []OperationFact{download, receipt}, ActionWaitGrant, false}, + {"contribute with exact unexpired grant", cp2, []OperationFact{grant}, ActionContribute, true}, + {"upload exact retained candidate", cp2, []OperationFact{grant, computed}, ActionUploadCandidate, true}, + {"wait after exact candidate upload", cp2, []OperationFact{grant, computed, uploaded}, ActionConfirmAcceptance, false}, + } + for _, view := range views { + t.Run(view.name, func(t *testing.T) { + got, err := RecommendPhase1At(view.cp, localFor(Participant, participantID, view.facts...), ObservedObjects{}, recommendationTime) + if err != nil || got.Action != view.want || got.Ready != view.ready { + t.Fatalf("got=%+v err=%v", got, err) + } + }) + } +} diff --git a/internal/storagefirst/local_facts.go b/internal/storagefirst/local_facts.go new file mode 100644 index 0000000..1d43fff --- /dev/null +++ b/internal/storagefirst/local_facts.go @@ -0,0 +1,208 @@ +package storagefirst + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +const LocalFactsSchema = "relay-storage-first-local-facts-v1" + +func validateLocalFacts(facts LocalFacts) error { + if facts.Schema != LocalFactsSchema { + return fmt.Errorf("local facts schema %q, want %q", facts.Schema, LocalFactsSchema) + } + if facts.Role != Coordinator && facts.Role != Participant { + return errors.New("local facts role is invalid") + } + if facts.Role == Participant && !validComponent(facts.IdentityID) { + return errors.New("participant local facts require an identity") + } + seen := map[string]struct{}{} + for i, fact := range facts.Operations { + if err := fact.Validate(); err != nil { + return fmt.Errorf("operation %d: %w", i, err) + } + if facts.Role == Participant && fact.IdentityID != facts.IdentityID { + return fmt.Errorf("operation %d belongs to another participant", i) + } + key := string(fact.Kind) + "\x00" + fact.CheckpointDigest + "\x00" + fact.Phase + "\x00" + fmt.Sprint(fact.Index) + "\x00" + fact.IdentityID + "\x00" + fact.AttemptID + "\x00" + fact.ArtifactDigest + if _, ok := seen[key]; ok { + return fmt.Errorf("operation %d duplicates an exact durable fact", i) + } + seen[key] = struct{}{} + } + return nil +} + +func (f OperationFact) Validate() error { + switch f.Kind { + case OperationOutboundDownloaded, OperationReceiptUploaded, OperationCandidateGrant, OperationCandidateComputed, OperationCandidateUploaded: + default: + return fmt.Errorf("unsupported operation kind %q", f.Kind) + } + if !validDigest(f.CheckpointDigest) || !validDigest(f.ArtifactDigest) { + return errors.New("operation checkpoint and artifact digests must be tagged SHA-256") + } + if f.Phase != "phase1" || f.Index < 1 || f.Index > 255 || !validComponent(f.IdentityID) || !validAttempt(f.AttemptID) { + return errors.New("operation scope is invalid") + } + if f.Kind == OperationCandidateGrant { + parsed, err := time.Parse(time.RFC3339, f.GrantExpiresAt) + if err != nil || f.GrantExpiresAt != parsed.UTC().Format(time.RFC3339) { + return errors.New("candidate grant fact requires canonical RFC3339 UTC expiry") + } + } else if f.GrantExpiresAt != "" { + return errors.New("only candidate grant facts may contain grant expiry") + } + return nil +} + +func SaveLocalFacts(path string, facts LocalFacts) error { + if err := validateLocalFacts(facts); err != nil { + return err + } + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return errors.New("local facts path must be absolute and clean") + } + raw, err := json.MarshalIndent(facts, "", " ") + if err != nil { + return err + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + unlock, err := lockLocalFacts(path + ".lock") + if err != nil { + return err + } + defer unlock() + if existing, err := LoadLocalFacts(path); err == nil { + if !factsContain(facts, existing) { + return errors.New("local facts changed since they were read; reload before saving") + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read existing local facts before save: %w", err) + } + temp, err := os.CreateTemp(dir, ".local-facts-") + if err != nil { + return err + } + tempName := temp.Name() + keep := false + defer func() { + _ = temp.Close() + if !keep { + _ = os.Remove(tempName) + } + }() + if err := temp.Chmod(0o600); err != nil { + return err + } + if _, err := temp.Write(raw); err != nil { + return err + } + if err := temp.Sync(); err != nil { + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Rename(tempName, path); err != nil { + return err + } + keep = true + d, err := os.Open(dir) + if err != nil { + return err + } + defer d.Close() + return d.Sync() +} + +func factsContain(next, previous LocalFacts) bool { + if next.Schema != previous.Schema || next.Role != previous.Role || next.IdentityID != previous.IdentityID { + return false + } + for _, required := range previous.Operations { + found := false + for _, candidate := range next.Operations { + if candidate == required { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func LoadLocalFacts(path string) (LocalFacts, error) { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return LocalFacts{}, errors.New("local facts path must be absolute and clean") + } + info, err := os.Lstat(path) + if err != nil { + return LocalFacts{}, err + } + if !info.Mode().IsRegular() { + return LocalFacts{}, errors.New("local facts must be a regular file") + } + if info.Size() <= 0 || info.Size() > 1<<20 { + return LocalFacts{}, errors.New("local facts size is outside the allowed range") + } + file, err := os.Open(path) + if err != nil { + return LocalFacts{}, err + } + defer file.Close() + decoder := json.NewDecoder(io.LimitReader(file, 1<<20)) + decoder.DisallowUnknownFields() + var facts LocalFacts + if err := decoder.Decode(&facts); err != nil { + return LocalFacts{}, err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return LocalFacts{}, errors.New("local facts contain trailing JSON") + } + if err := validateLocalFacts(facts); err != nil { + return LocalFacts{}, err + } + return facts, nil +} + +func validDigest(value string) bool { + if len(value) != 71 || !strings.HasPrefix(value, "sha256:") { + return false + } + for _, r := range strings.TrimPrefix(value, "sha256:") { + if !strings.ContainsRune("0123456789abcdef", r) { + return false + } + } + return true +} + +func validAttempt(value string) bool { + if len(value) != 32 { + return false + } + for _, r := range value { + if !strings.ContainsRune("0123456789abcdef", r) { + return false + } + } + return true +} + +func validComponent(value string) bool { + return value != "" && len(value) <= 128 && filepath.Base(value) == value && value != "." && value != ".." && !strings.ContainsAny(value, `/\\`) +} diff --git a/internal/storagefirst/local_facts_lock_unix.go b/internal/storagefirst/local_facts_lock_unix.go new file mode 100644 index 0000000..d2288fc --- /dev/null +++ b/internal/storagefirst/local_facts_lock_unix.go @@ -0,0 +1,24 @@ +//go:build darwin || linux + +package storagefirst + +import ( + "fmt" + "os" + "syscall" +) + +func lockLocalFacts(path string) (func(), error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + _ = file.Close() + return nil, fmt.Errorf("lock local facts: %w", err) + } + return func() { + _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + _ = file.Close() + }, nil +} diff --git a/internal/storagefirst/local_facts_test.go b/internal/storagefirst/local_facts_test.go new file mode 100644 index 0000000..0562ded --- /dev/null +++ b/internal/storagefirst/local_facts_test.go @@ -0,0 +1,71 @@ +package storagefirst + +import ( + "path/filepath" + "testing" +) + +func TestExactOperationFactsSurviveRestart(t *testing.T) { + cp := actionCheckpoint(2, "phase1-receipt-accepted", "participant-1") + grant := operation(OperationCandidateGrant, cp, cp.Slots[0], digestOfTest("4")) + facts := localFor(Participant, "participant-1", grant) + path := filepath.Join(t.TempDir(), "workflow", "local-facts.json") + if err := SaveLocalFacts(path, facts); err != nil { + t.Fatal(err) + } + restarted, err := LoadLocalFacts(path) + if err != nil { + t.Fatal(err) + } + got, err := RecommendPhase1At(cp, restarted, ObservedObjects{}, recommendationTime) + if err != nil || got.Action != ActionContribute || !got.Ready { + t.Fatalf("restart recommendation=%+v err=%v", got, err) + } +} + +func TestRestartedStaleFactDoesNotApplyToReplacementAttempt(t *testing.T) { + cp := actionCheckpoint(2, "phase1-receipt-accepted", "participant-1") + grant := operation(OperationCandidateGrant, cp, cp.Slots[0], digestOfTest("4")) + path := filepath.Join(t.TempDir(), "local-facts.json") + if err := SaveLocalFacts(path, localFor(Participant, "participant-1", grant)); err != nil { + t.Fatal(err) + } + restarted, err := LoadLocalFacts(path) + if err != nil { + t.Fatal(err) + } + replacement := cp + replacement.Slots = append([]Slot(nil), cp.Slots...) + replacement.Slots[0].AttemptID = string(makeHex("e", 32)) + replacement.Slots[0].ManifestKey = "submissions/candidate/" + replacement.Slots[0].AttemptID + "/manifest.json" + got, err := RecommendPhase1At(replacement, restarted, ObservedObjects{}, recommendationTime) + if err != nil || got.Action != ActionWaitGrant || got.Ready { + t.Fatalf("stale restart fact applied=%+v err=%v", got, err) + } +} + +func TestLocalFactsRejectUnknownFieldsAndCrossIdentityFacts(t *testing.T) { + cp := actionCheckpoint(2, "phase1-receipt-accepted", "participant-1") + fact := operation(OperationCandidateGrant, cp, cp.Slots[0], digestOfTest("4")) + facts := localFor(Participant, "participant-2", fact) + if err := SaveLocalFacts(filepath.Join(t.TempDir(), "facts.json"), facts); err == nil { + t.Fatal("cross-identity facts saved") + } +} + +func TestSaveLocalFactsRejectsStaleWriterDroppingAnotherOperation(t *testing.T) { + path := filepath.Join(t.TempDir(), "facts.json") + cp := actionCheckpoint(1, "phase1-outbound-published", "participant-1") + first := operation(OperationOutboundDownloaded, cp, cp.Slots[0], digestOfTest("4")) + second := operation(OperationReceiptUploaded, cp, cp.Slots[0], digestOfTest("5")) + base := localFor(Participant, "participant-1", first) + if err := SaveLocalFacts(path, base); err != nil { + t.Fatal(err) + } + if err := SaveLocalFacts(path, localFor(Participant, "participant-1", first, second)); err != nil { + t.Fatal(err) + } + if err := SaveLocalFacts(path, base); err == nil { + t.Fatal("stale whole-file writer discarded a completed operation") + } +} diff --git a/internal/storagefirst/outbound_v4.go b/internal/storagefirst/outbound_v4.go new file mode 100644 index 0000000..9a8ae22 --- /dev/null +++ b/internal/storagefirst/outbound_v4.go @@ -0,0 +1,86 @@ +package storagefirst + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/zksecurity/relay/internal/transcript" +) + +// ReceivedOutboundV4 describes SHA-256/size-checked receipt inputs, not a signed +// receipt or a computation-ready transcript. The caller retains Root and passes +// its named files to proof-tool, which checks signatures and both signed digests +// before preparing/signing a receipt. No persistent completion flag is created. +type ReceivedOutboundV4 struct { + Root string + Scope transcript.ContributionScopeV4 + AttemptID string + Predecessor transcript.SignedArtifactRefs + Handoff transcript.SignedArtifactRefs + Files []transcript.ArtifactRef +} + +func (s SnapshotV4) FetchOutboundV4(objects ObjectStore, protocol transcript.DefinitionProtocol, phase, identity, parent string) (result ReceivedOutboundV4, err error) { + if objects == nil || identity == "" { + return result, errors.New("participant and public object store required") + } + view, err := s.TurnV4(protocol, phase, identity) + if err != nil { + return result, err + } + if view.Stage != TurnReceiptV4 || view.ReceiptAttempt == nil || view.Commitment == nil || len(view.Commitment.Outbounds) == 0 { + return result, errors.New("no active input-packet delivery for this participant") + } + c, err := s.State() + if err != nil { + return result, err + } + if c.Definition != protocol.DefinitionRefs { + return result, errors.New("backend and authenticated definition references differ") + } + progress := c.Progress.Phase1 + if phase == "phase2" { + if c.Progress.Phase2 == nil { + return result, errors.New("Phase 2 has not started") + } + progress = *c.Progress.Phase2 + } + handoff := view.Commitment.Outbounds[0].Pair + refs := []transcript.ArtifactRef{c.Definition.Record, c.Definition.Signature, progress.Chain.Record, progress.Chain.Signature, handoff.Record, handoff.Signature, progress.HeadPayload} + // Validate the entire named set before any fetch. Only the signed current + // head may use the large-payload bound; metadata remains small. + seen := map[string]bool{} + for _, ref := range refs { + limit := int64(16 << 20) + if ref == progress.HeadPayload { + limit = 16 << 30 + } else if ref == c.Definition.Signature || ref == progress.Chain.Signature || ref == handoff.Signature { + limit = 4096 + } + if err := transcript.ValidateName(ref.Name); err != nil { + return result, err + } + if seen[ref.Name] || !validDigest(ref.Digest.SHA256) || !strings.HasPrefix(ref.Digest.Blake2b256, "blake2b256:") || !validDigest(strings.Replace(ref.Digest.Blake2b256, "blake2b256:", "sha256:", 1)) || ref.Digest.Size <= 0 || ref.Digest.Size > limit { + return result, errors.New("invalid, duplicate or oversized outbound input reference") + } + seen[ref.Name] = true + } + root, err := os.MkdirTemp(parent, "received-turn-") + if err != nil { + return result, err + } + defer func() { + if err != nil { + _ = os.RemoveAll(root) + } + }() + names := fetchedNames{} + for _, ref := range refs { + if _, err = fetchNamed(objects, root, contentRef(ref), names); err != nil { + return result, fmt.Errorf("download turn input %s: %w", ref.Name, err) + } + } + return ReceivedOutboundV4{Root: root, Scope: view.Scope, AttemptID: view.ReceiptAttempt.AttemptID, Predecessor: progress.Chain, Handoff: handoff, Files: refs}, nil +} diff --git a/internal/storagefirst/outbound_v4_test.go b/internal/storagefirst/outbound_v4_test.go new file mode 100644 index 0000000..8e6afb8 --- /dev/null +++ b/internal/storagefirst/outbound_v4_test.go @@ -0,0 +1,124 @@ +package storagefirst + +import ( + "os" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +func outboundFixtureV4(t *testing.T, phase string) (SnapshotV4, transcript.DefinitionProtocol, memoryObjects, string) { + _, p, c, index, e := turnFixtureV4(t, phase) + objects := memoryObjects{} + ref := func(name string) transcript.ArtifactRef { + raw := []byte("public bytes for " + name) + sha := sum(raw) + objects[store.Key(sha)] = raw + return transcript.ArtifactRef{Name: name, Digest: transcript.Digest{SHA256: sha, Blake2b256: "blake2b256:" + strings.Repeat("a", 64), Size: int64(len(raw))}} + } + pair := func(name string) transcript.SignedArtifactRefs { + return transcript.SignedArtifactRefs{Record: ref(name + ".json"), Signature: ref(name + ".sig")} + } + c.Definition = pair("ceremony") + p.DefinitionRefs = c.Definition + progress := &c.Progress.Phase1 + if phase == "phase2" { + progress = c.Progress.Phase2 + } + progress.Chain = pair(phase + "/chain-0000") + progress.HeadPayload = ref(phase + "/genesis.bin") + schedule, _ := p.Definition.Schedule(phase) + who := schedule[0] + view, err := encodeTurnFixtureV4(t, c, index, e).TurnV4(p, phase, who) + if err != nil { + t.Fatal(err) + } + attempt := strings.Repeat("ab", 16) + index.Turns = []transcript.TurnCommitmentV4{{Scope: view.Scope, Outbounds: []transcript.OutboundCommitmentV4{{Pair: pair("custody/outbound"), PublishedAttemptID: attempt, CheckpointSequence: 1}}}} + c.Deliveries = []transcript.DeliverySlotV4{{Scope: view.Scope, Kind: "receipt", AttemptID: attempt, Status: "allocated"}} + return encodeTurnFixtureV4(t, c, index, e), p, objects, who +} + +func TestFetchOutboundV4RejectsReceiptEraState(t *testing.T) { + for _, phase := range []string{"phase1", "phase2"} { + s, p, objects, who := outboundFixtureV4(t, phase) + parent := t.TempDir() + if _, err := s.FetchOutboundV4(objects, p, phase, who, parent); err == nil { + t.Fatal("receipt-era outbound state was accepted by V4") + } + } +} + +func TestFetchOutboundV4FailureRetainsNoReturnedDirectory(t *testing.T) { + s, p, objects, who := outboundFixtureV4(t, "phase1") + for _, broken := range []string{"missing", "corrupt"} { + t.Run(broken, func(t *testing.T) { + copy := memoryObjects{} + for key, raw := range objects { + copy[key] = raw + } + c, _ := s.State() + key := store.Key(c.Progress.Phase1.HeadPayload.Digest.SHA256) + if broken == "missing" { + delete(copy, key) + } else { + copy[key] = []byte(strings.Repeat("x", len(copy[key]))) + } + parent := t.TempDir() + result, err := s.FetchOutboundV4(copy, p, "phase1", who, parent) + if err == nil || result.Root != "" { + t.Fatal("failed download returned usable inputs") + } + entries, err := os.ReadDir(parent) + if err != nil || len(entries) != 0 { + t.Fatal("failed staging retained", err) + } + }) + } + if _, err := s.FetchOutboundV4(objects, p, "phase1", "p2", t.TempDir()); err == nil { + t.Fatal("later participant downloaded another turn") + } + c, _ := s.State() + index, _ := s.Commitments() + e, _ := s.Enrollments() + c.Deliveries[0].Status = "retired" + if _, err := encodeTurnFixtureV4(t, c, index, e).FetchOutboundV4(objects, p, "phase1", who, t.TempDir()); err == nil { + t.Fatal("retired delivery offered") + } +} + +func TestFetchOutboundV4RejectsUnsafeInventoryBeforeStaging(t *testing.T) { + s, p, objects, who := outboundFixtureV4(t, "phase1") + for _, mutate := range []func(*transcript.CheckpointStateV4, *transcript.CheckpointCommitmentsV4){ + func(c *transcript.CheckpointStateV4, _ *transcript.CheckpointCommitmentsV4) { + c.Progress.Phase1.HeadPayload.Name = "../outside" + }, + func(c *transcript.CheckpointStateV4, _ *transcript.CheckpointCommitmentsV4) { + c.Progress.Phase1.HeadPayload.Digest.Size = (16 << 30) + 1 + }, + func(c *transcript.CheckpointStateV4, _ *transcript.CheckpointCommitmentsV4) { + c.Progress.Phase1.Chain.Signature.Digest.Size = 4097 + }, + func(c *transcript.CheckpointStateV4, i *transcript.CheckpointCommitmentsV4) { + i.Turns[0].Outbounds[0].Pair.Record.Name = c.Progress.Phase1.Chain.Record.Name + }, + func(c *transcript.CheckpointStateV4, _ *transcript.CheckpointCommitmentsV4) { + c.Definition.Record.Digest.SHA256 = sum([]byte("other ceremony")) + }, + } { + c, _ := s.State() + index, _ := s.Commitments() + e, _ := s.Enrollments() + mutate(&c, &index) + parent := t.TempDir() + if _, err := encodeTurnFixtureV4(t, c, index, e).FetchOutboundV4(objects, p, "phase1", who, parent); err == nil { + t.Fatal("unsafe input set accepted") + } + entries, err := os.ReadDir(parent) + if err != nil || len(entries) != 0 { + t.Fatal("invalid metadata created staging", err) + } + } +} diff --git a/internal/storagefirst/proof_tool.go b/internal/storagefirst/proof_tool.go new file mode 100644 index 0000000..784cdfd --- /dev/null +++ b/internal/storagefirst/proof_tool.go @@ -0,0 +1,118 @@ +package storagefirst + +import ( + "path/filepath" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/transcript" +) + +// ProofToolVerifier adapts the approved mpc-ceremony inspection projection to +// the deliberately smaller storage synchronizer interface. +type ProofToolVerifier struct{ Inspector transcript.Inspector } + +func (v ProofToolVerifier) VerifyCheckpoint(checkpointPath, signaturePath string) (Checkpoint, error) { + inspection, err := v.Inspector.Checkpoint(checkpointPath, signaturePath) + if err != nil { + return Checkpoint{}, err + } + definition, err := v.Inspector.Definition() + if err != nil { + return Checkpoint{}, err + } + checkpoint := Checkpoint{ + CeremonyID: inspection.CeremonyID, + Position: state.CheckpointPosition{ + Sequence: inspection.Sequence, + Digest: inspection.Digest.SHA256, + PhaseHeads: map[string]state.PhaseHeadPosition{ + "phase1": {Index: uint64(inspection.Phase1.AcceptedCount), Digest: inspection.Phase1.HeadRecordID, Closed: inspection.Phase1Closure != nil}, + }, + }, + Transition: inspection.Transition.Kind, + ParticipantID: inspection.Transition.ParticipantID, + Phase1Accepted: int(inspection.Phase1.AcceptedCount), + Phase1ScheduledTotal: len(definition.Phase1Participants), + Phase2ScheduledTotal: len(definition.Phase2Participants), + FinalCandidateRecorded: inspection.FinalCandidate != nil, + FinalReleaseRecorded: inspection.FinalRelease != nil, + } + checkpoint.Definition = SignedRef{Checkpoint: contentRef(inspection.Definition.Record), Signature: contentRef(inspection.Definition.Signature)} + for _, artifact := range inspection.AcceptedArtifacts { + checkpoint.Artifacts = append(checkpoint.Artifacts, contentRef(artifact)) + } + checkpoint.Phase1Closed = inspection.Phase1Closure != nil + if !checkpoint.Phase1Closed && checkpoint.Phase1Accepted < checkpoint.Phase1ScheduledTotal { + checkpoint.Phase1NextParticipantID = definition.Phase1Participants[checkpoint.Phase1Accepted] + } + if inspection.Phase2 != nil { + checkpoint.Phase2Accepted = int(inspection.Phase2.AcceptedCount) + checkpoint.Phase2Closed = inspection.Phase2Closure != nil + checkpoint.Position.PhaseHeads["phase2"] = state.PhaseHeadPosition{ + Index: uint64(inspection.Phase2.AcceptedCount), Digest: inspection.Phase2.HeadRecordID, Closed: checkpoint.Phase2Closed, + } + if !checkpoint.Phase2Closed && checkpoint.Phase2Accepted < checkpoint.Phase2ScheduledTotal { + checkpoint.Phase2NextParticipantID = definition.Phase2Participants[checkpoint.Phase2Accepted] + } + } + if inspection.PreviousCheckpoint != nil { + previous := SignedRef{ + Checkpoint: contentRef(inspection.PreviousCheckpoint.Record), + Signature: contentRef(inspection.PreviousCheckpoint.Signature), + } + checkpoint.Previous = &previous + checkpoint.Position.PreviousDigest = previous.Checkpoint.SHA256 + } + for _, slot := range inspection.Submissions { + projected := Slot{ + Kind: slot.Kind, Phase: slot.Phase, Index: int(slot.Index), IdentityID: slot.IdentityID, + AttemptID: slot.AttemptID, ManifestKey: slot.ManifestKey, + BasisCheckpointDigest: slot.BasisCheckpointSHA256, ParentHeadID: slot.ParentHeadID, Status: slot.Status, + } + if slot.Acknowledgement != nil { + projected.AcknowledgementDigest = slot.Acknowledgement.Record.Digest.SHA256 + } + checkpoint.Slots = append(checkpoint.Slots, projected) + if inspection.PreviousCheckpoint != nil && inspection.Transition.Kind == "phase1-candidate-accepted" && projected.Kind == "candidate" && + projected.Status == "accepted" && projected.Index == int(inspection.Transition.Index) && + projected.IdentityID == inspection.Transition.ParticipantID && projected.AttemptID == inspection.Transition.AttemptID { + checkpoint.AcceptedCandidate = &AcceptedCandidate{ + OperationCheckpointDigest: inspection.PreviousCheckpoint.Record.Digest.SHA256, + BasisCheckpointDigest: projected.BasisCheckpointDigest, Index: projected.Index, + IdentityID: projected.IdentityID, AttemptID: projected.AttemptID, + CandidateDigest: inspection.Phase1.HeadPayload.Digest.SHA256, + AcceptedHeadID: inspection.Phase1.HeadRecordID, + AcknowledgementDigest: projected.AcknowledgementDigest, + } + } + } + return checkpoint, nil +} + +func (v ProofToolVerifier) VerifyTransition(previousCheckpointPath, previousSignaturePath, nextCheckpointPath, nextSignaturePath string) error { + _, err := v.Inspector.CheckpointTransition(previousCheckpointPath, previousSignaturePath, nextCheckpointPath, nextSignaturePath) + return err +} + +func (v ProofToolVerifier) VerifyEvidence(artifactRoot, checkpointPath, signaturePath string) (EvidenceVerification, error) { + inspector := v.Inspector + checkpoint, err := inspector.Checkpoint(checkpointPath, signaturePath) + if err != nil { + return EvidenceVerification{}, err + } + inspector.CeremonyPath = filepath.Join(artifactRoot, filepath.FromSlash(checkpoint.Definition.Record.Name)) + inspector.CeremonySignaturePath = filepath.Join(artifactRoot, filepath.FromSlash(checkpoint.Definition.Signature.Name)) + inspection, err := inspector.CheckpointEvidence(artifactRoot, checkpointPath, signaturePath) + if err != nil { + return EvidenceVerification{}, err + } + return EvidenceVerification{ + CeremonyID: inspection.CeremonyID, Sequence: inspection.Sequence, + Digest: inspection.CheckpointDigest.SHA256, TransitionKind: inspection.TransitionKind, + FullyVerified: inspection.FullyVerified, + }, nil +} + +func contentRef(ref transcript.ArtifactRef) state.ContentRef { + return state.ContentRef{Name: ref.Name, SHA256: ref.Digest.SHA256, Size: ref.Digest.Size} +} diff --git a/internal/storagefirst/proof_tool_test.go b/internal/storagefirst/proof_tool_test.go new file mode 100644 index 0000000..85f552d --- /dev/null +++ b/internal/storagefirst/proof_tool_test.go @@ -0,0 +1,97 @@ +package storagefirst + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/transcript" +) + +func TestProofToolEvidenceUsesFetchedDefinitionInsideArtifactRoot(t *testing.T) { + root := t.TempDir() + digest := transcript.Digest{SHA256: "sha256:" + strings.Repeat("a", 64), Size: 10} + definition := transcript.SignedArtifactRefs{ + Record: transcript.ArtifactRef{Name: "ceremony/ceremony.json", Digest: digest}, + Signature: transcript.ArtifactRef{Name: "ceremony/ceremony.sig", Digest: digest}, + } + inspector := transcript.Inspector{ + CeremonyPath: "original/ceremony.json", CeremonySignaturePath: "original/ceremony.sig", CoordinatorPublicKeyPath: "trust/coordinator.hex", + } + inspector.Runner = func(_ string, args ...string) ([]byte, []byte, error) { + joined := strings.Join(args, " ") + var result map[string]any + switch { + case strings.Contains(joined, "inspect checkpoint"): + result = map[string]any{ + "schema": "proof-tool-mpc-command-result-v1", "ok": true, "command": "inspect checkpoint", + "checkpoint_inspection": map[string]any{ + "schema": "proof-tool-mpc-checkpoint-inspection-v1", "ceremony_id": "sha256:" + strings.Repeat("c", 64), + "workflow": "storage-first-v1", "relay_release_id": "release", "sequence": 0, "digest": digest, + "definition": definition, "transition": map[string]any{"kind": "initial"}, + "phase1": map[string]any{"phase": "phase1", "accepted_count": 0, "head_record_id": "sha256:" + strings.Repeat("d", 64), "head_payload": transcript.ArtifactRef{Name: "phase1/genesis.bin", Digest: digest}, "chain": transcript.SignedArtifactRefs{Record: transcript.ArtifactRef{Name: "phase1/chain-0000.json", Digest: digest}, Signature: transcript.ArtifactRef{Name: "phase1/chain-0000.sig", Digest: digest}}}, + "accepted_artifacts": []transcript.ArtifactRef{definition.Record, definition.Signature, {Name: "phase1/genesis.bin", Digest: digest}}, + }, + } + case strings.Contains(joined, "checkpoint verify-stored"): + wantCeremony := "--ceremony " + filepath.Join(root, "ceremony/ceremony.json") + wantSignature := "--ceremony-signature " + filepath.Join(root, "ceremony/ceremony.sig") + if !strings.Contains(joined, wantCeremony) || !strings.Contains(joined, wantSignature) { + t.Fatalf("deep verification did not use fetched definition: %s", joined) + } + result = map[string]any{ + "schema": "proof-tool-mpc-command-result-v1", "ok": true, "command": "checkpoint verify-stored", + "checkpoint_evidence_inspection": map[string]any{ + "schema": "proof-tool-mpc-checkpoint-evidence-inspection-v1", "ceremony_id": "sha256:" + strings.Repeat("c", 64), + "sequence": 0, "checkpoint_digest": digest, "transition_kind": "initial", "fully_verified": true, "verified_evidence_boundary": "cp0", + }, + } + default: + t.Fatalf("unexpected args: %s", joined) + } + raw, _ := json.Marshal(result) + return raw, nil, nil + } + got, err := (ProofToolVerifier{Inspector: inspector}).VerifyEvidence(root, filepath.Join(root, "checkpoints/0.json"), filepath.Join(root, "checkpoints/0.sig")) + if err != nil || !got.FullyVerified { + t.Fatalf("got=%+v err=%v", got, err) + } +} + +func TestProofToolCheckpointProjectsBothPhasesAndFinalState(t *testing.T) { + digest := transcript.Digest{SHA256: "sha256:" + strings.Repeat("a", 64), Size: 10} + ref := func(name string) transcript.ArtifactRef { return transcript.ArtifactRef{Name: name, Digest: digest} } + signed := func(name string) *transcript.SignedArtifactRefs { + return &transcript.SignedArtifactRefs{Record: ref(name + ".json"), Signature: ref(name + ".sig")} + } + definition := transcript.Definition{Schema: "proof-tool-mpc-definition-inspection-v1", CeremonyID: "sha256:" + strings.Repeat("c", 64), Mode: "rehearsal", Phase1Participants: []string{"participant-1"}, Phase2Participants: []string{"participant-1"}, R1CSRef: ref("circuit.ccs")} + inspection := transcript.CheckpointInspection{ + Schema: "proof-tool-mpc-checkpoint-inspection-v1", CeremonyID: definition.CeremonyID, + Workflow: "storage-first-v1", RelayReleaseID: "release", Sequence: 14, Digest: digest, + Definition: *signed("ceremony"), Transition: transcript.CheckpointTransition{Kind: "final-release-recorded"}, + Phase1: transcript.CheckpointPhaseState{Phase: "phase1", AcceptedCount: 1, HeadRecordID: "sha256:" + strings.Repeat("1", 64), HeadPayload: ref("phase1/head.bin"), Chain: *signed("phase1/chain")}, + Phase1Closure: signed("phase1/close"), Phase1Beacon: signed("phase1/beacon"), Phase1Seal: signed("phase1/seal"), + Phase2: &transcript.CheckpointPhaseState{Phase: "phase2", AcceptedCount: 1, HeadRecordID: "sha256:" + strings.Repeat("2", 64), HeadPayload: ref("phase2/head.bin"), Chain: *signed("phase2/chain")}, + Phase2Closure: signed("phase2/close"), Phase2Beacon: signed("phase2/beacon"), + FinalCandidate: signed("final/candidate"), FinalRelease: signed("final/release"), + } + inspector := transcript.Inspector{CeremonyPath: "ceremony.json", CeremonySignaturePath: "ceremony.sig", CoordinatorPublicKeyPath: "coordinator.hex"} + inspector.Runner = func(_ string, args ...string) ([]byte, []byte, error) { + var result any + if strings.Contains(strings.Join(args, " "), "inspect definition") { + result = map[string]any{"schema": "proof-tool-mpc-command-result-v1", "ok": true, "command": "inspect definition", "definition_inspection": definition} + } else { + result = map[string]any{"schema": "proof-tool-mpc-command-result-v1", "ok": true, "command": "inspect checkpoint", "checkpoint_inspection": inspection} + } + raw, _ := json.Marshal(result) + return raw, nil, nil + } + got, err := (ProofToolVerifier{Inspector: inspector}).VerifyCheckpoint("checkpoint.json", "checkpoint.sig") + if err != nil { + t.Fatal(err) + } + if !got.Phase1Closed || !got.Phase2Closed || got.Phase2Accepted != 1 || !got.FinalCandidateRecorded || !got.FinalReleaseRecorded || !got.Position.PhaseHeads["phase2"].Closed { + t.Fatalf("incomplete lifecycle projection: %+v", got) + } +} diff --git a/internal/storagefirst/submission.go b/internal/storagefirst/submission.go new file mode 100644 index 0000000..e168ac0 --- /dev/null +++ b/internal/storagefirst/submission.go @@ -0,0 +1,55 @@ +package storagefirst + +import ( + "errors" + + "github.com/zksecurity/relay/internal/transcript" +) + +type SubmissionInspectionFiles struct { + CheckpointPath string + CheckpointSignaturePath string + EnvelopePath string + EnvelopeSignaturePath string + ManifestPath string +} + +// AuthenticateObservedSubmission returns an opaque readiness fact only after +// the approved proof-tool authenticates the exact allocated submission. A +// storage LIST or HEAD result is intentionally insufficient. +func AuthenticateObservedSubmission(verifier ProofToolVerifier, checkpoint Checkpoint, slot Slot, files SubmissionInspectionFiles) (AuthenticatedObservedSubmission, error) { + if !checkpoint.authenticatedEvidence { + return AuthenticatedObservedSubmission{}, errors.New("submission authentication requires a fully evidence-verified checkpoint") + } + found := false + for _, candidate := range checkpoint.Slots { + if candidate == slot { + found = true + break + } + } + if !found || slot.Status != "allocated" { + return AuthenticatedObservedSubmission{}, errors.New("submission does not target an exact allocated checkpoint slot") + } + inspection, err := verifier.Inspector.Submission(transcript.SubmissionInspectionPaths{ + CheckpointPath: files.CheckpointPath, CheckpointSignaturePath: files.CheckpointSignaturePath, + Kind: slot.Kind, Phase: slot.Phase, Index: uint8(slot.Index), SubmitterID: slot.IdentityID, + AttemptID: slot.AttemptID, EnvelopePath: files.EnvelopePath, + EnvelopeSignaturePath: files.EnvelopeSignaturePath, ManifestPath: files.ManifestPath, + }) + if err != nil { + return AuthenticatedObservedSubmission{}, err + } + if inspection.CeremonyID != checkpoint.CeremonyID || inspection.Workflow != "storage-first-v1" || + inspection.Kind != slot.Kind || inspection.Phase != slot.Phase || int(inspection.Index) != slot.Index || + inspection.SubmitterID != slot.IdentityID || inspection.AttemptID != slot.AttemptID || + inspection.ManifestKey != slot.ManifestKey || inspection.ParentCheckpointSHA256 != slot.BasisCheckpointDigest || + inspection.AllocationCheckpointSHA256 != checkpoint.Position.Digest || inspection.ParentHeadID != slot.ParentHeadID || + !validDigest(inspection.ManifestDigest.SHA256) { + return AuthenticatedObservedSubmission{}, errors.New("authenticated submission projection does not match the exact checkpoint slot") + } + return AuthenticatedObservedSubmission{ + ceremonyID: checkpoint.CeremonyID, checkpointDigest: checkpoint.Position.Digest, + slot: slot, manifestDigest: inspection.ManifestDigest.SHA256, + }, nil +} diff --git a/internal/storagefirst/submission_test.go b/internal/storagefirst/submission_test.go new file mode 100644 index 0000000..180ac75 --- /dev/null +++ b/internal/storagefirst/submission_test.go @@ -0,0 +1,117 @@ +package storagefirst + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/transcript" +) + +func submissionInspectionFor(cp Checkpoint, slot Slot) map[string]any { + digest := map[string]any{ + "sha256": "sha256:" + strings.Repeat("d", 64), + "blake2b256": "blake2b256:" + strings.Repeat("e", 64), + "size": 32, + } + return map[string]any{ + "schema": "proof-tool-mpc-submission-inspection-v1", "ceremony_id": cp.CeremonyID, + "workflow": "storage-first-v1", "relay_release_id": "role-images-test", + "submitter_id": slot.IdentityID, "submitter_key_id": "participant-key", "submitter_role": "participant", + "kind": slot.Kind, "phase": slot.Phase, "index": slot.Index, + "parent_checkpoint_sha256": slot.BasisCheckpointDigest, + "allocation_checkpoint_sha256": cp.Position.Digest, "parent_head_id": slot.ParentHeadID, + "attempt_id": slot.AttemptID, "manifest_key": slot.ManifestKey, + "payloads": []any{map[string]any{"name": "submissions/payload.bin", "digest": digest}}, + "envelope_digest": digest, "envelope_signature_digest": digest, "manifest_digest": digest, + } +} + +func TestAuthenticateObservedSubmissionRequiresExactProofToolVerification(t *testing.T) { + cp := actionCheckpoint(1, "phase1-outbound-published", "participant-1") + cp.CeremonyID = digestOfTest("0") + slot := cp.Slots[0] + projection := submissionInspectionFor(cp, slot) + mode := "valid" + inspector := transcript.Inspector{ + Executable: "/approved/mpc-ceremony", CeremonyPath: "/work/ceremony.json", + CeremonySignaturePath: "/work/ceremony.sig", CoordinatorPublicKeyPath: "/trust/coordinator.hex", + Runner: func(_ string, args ...string) ([]byte, []byte, error) { + joined := strings.Join(args, " ") + if !strings.Contains(joined, "--attempt-id "+slot.AttemptID) || + !strings.Contains(joined, "--manifest /inbox/manifest.json") { + t.Fatalf("proof-tool did not receive the exact slot and manifest: %q", args) + } + if mode == "forged" { + message := "submission signature verification failed" + raw, err := json.Marshal(map[string]any{ + "schema": "proof-tool-mpc-command-result-v1", "ok": false, + "command": "inspect submission", "error": map[string]any{"code": "invalid_input", "message": message}, + }) + return raw, nil, err + } + if mode == "oversized" { + projection["manifest_digest"] = map[string]any{ + "sha256": "sha256:" + strings.Repeat("d", 64), + "blake2b256": "blake2b256:" + strings.Repeat("e", 64), + "size": 16<<20 + 1, + } + } + raw, err := json.Marshal(map[string]any{ + "schema": "proof-tool-mpc-command-result-v1", "ok": true, + "command": "inspect submission", "submission_inspection": projection, + }) + return raw, nil, err + }, + } + files := SubmissionInspectionFiles{ + CheckpointPath: "/work/checkpoint.json", CheckpointSignaturePath: "/work/checkpoint.sig", + EnvelopePath: "/inbox/envelope.json", EnvelopeSignaturePath: "/inbox/envelope.sig", + ManifestPath: "/inbox/manifest.json", + } + verified, err := AuthenticateObservedSubmission(ProofToolVerifier{Inspector: inspector}, cp, slot, files) + if err != nil { + t.Fatal(err) + } + got, err := RecommendPhase1At(cp, localFor(Coordinator, "coordinator"), ObservedSubmissions(verified), recommendationTime) + if err != nil || got.Action != ActionAcceptReceipt || !got.Ready { + t.Fatalf("authenticated submission did not become inspectable: %+v, %v", got, err) + } + replacementCheckpoint := cp + replacementCheckpoint.Position.Digest = digestOfTest("f") + got, err = RecommendPhase1At(replacementCheckpoint, localFor(Coordinator, "coordinator"), ObservedSubmissions(verified), recommendationTime) + if err != nil || got.Action != ActionWaitReceipt || got.Ready { + t.Fatalf("observation from another checkpoint advanced readiness: %+v, %v", got, err) + } + + projection["attempt_id"] = strings.Repeat("f", 32) + if _, err := AuthenticateObservedSubmission(ProofToolVerifier{Inspector: inspector}, cp, slot, files); err == nil { + t.Fatal("wrong-slot proof-tool projection accepted") + } + projection["attempt_id"] = slot.AttemptID + for _, rejectedMode := range []string{"forged", "oversized"} { + mode = rejectedMode + if _, err := AuthenticateObservedSubmission(ProofToolVerifier{Inspector: inspector}, cp, slot, files); err == nil { + t.Fatalf("%s manifest accepted without proof-tool verification", rejectedMode) + } + } +} + +func TestAuthenticateObservedSubmissionRejectsShallowOrUnallocatedSlot(t *testing.T) { + cp := actionCheckpoint(1, "phase1-outbound-published", "participant-1") + cp.CeremonyID = digestOfTest("0") + slot := cp.Slots[0] + verifier := ProofToolVerifier{Inspector: transcript.Inspector{Runner: func(_ string, _ ...string) ([]byte, []byte, error) { + return nil, nil, errors.New("must not run") + }}} + cp.authenticatedEvidence = false + if _, err := AuthenticateObservedSubmission(verifier, cp, slot, SubmissionInspectionFiles{}); err == nil { + t.Fatal("shallow checkpoint accepted") + } + cp.authenticatedEvidence = true + slot.Status = "accepted" + if _, err := AuthenticateObservedSubmission(verifier, cp, slot, SubmissionInspectionFiles{}); err == nil { + t.Fatal("unallocated slot accepted") + } +} diff --git a/internal/storagefirst/sync.go b/internal/storagefirst/sync.go new file mode 100644 index 0000000..ea9bfcc --- /dev/null +++ b/internal/storagefirst/sync.go @@ -0,0 +1,306 @@ +// Package storagefirst reconstructs authenticated ceremony progress from an +// untrusted object store. It deliberately knows nothing about Docker, grants, +// or CLI menus: callers receive verified public facts and combine them with +// their own private/local prerequisites. +package storagefirst + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/store" +) + +const ( + maxRootBytes = 64 << 10 + maxCheckpointCount = 1024 + maxAcceptedArtifacts = 8192 +) + +// SignedRef names one immutable checkpoint and its detached signature. +type SignedRef struct { + Checkpoint state.ContentRef + Signature state.ContentRef +} + +// Slot is a coordinator-preallocated submission location. The credential that +// permits writing it is private and may be renewed; this public attempt does +// not change when a credential expires. +type Slot struct { + Kind string + Phase string + Index int + IdentityID string + AttemptID string + ManifestKey string + BasisCheckpointDigest string + ParentHeadID string + Status string + AcknowledgementDigest string +} + +type AcceptedCandidate struct { + OperationCheckpointDigest string + BasisCheckpointDigest string + Index int + IdentityID string + AttemptID string + CandidateDigest string + AcceptedHeadID string + AcknowledgementDigest string +} + +// Checkpoint is the stable projection emitted by the trusted proof-tool after +// it authenticates a checkpoint. Relay must not derive these facts by parsing +// the signed JSON itself. +type Checkpoint struct { + CeremonyID string + Position state.CheckpointPosition + Previous *SignedRef + Definition SignedRef + Transition string + ParticipantID string + Phase1Accepted int + Phase1ScheduledTotal int + Phase1Closed bool + Phase1NextParticipantID string + Phase2Accepted int + Phase2ScheduledTotal int + Phase2Closed bool + Phase2NextParticipantID string + FinalCandidateRecorded bool + FinalReleaseRecorded bool + AcceptedCandidate *AcceptedCandidate + Slots []Slot + Artifacts []state.ContentRef + authenticatedEvidence bool +} + +// Verifier is normally backed by the approved mpc-ceremony binary. +type Verifier interface { + VerifyCheckpoint(checkpointPath, signaturePath string) (Checkpoint, error) + VerifyTransition(previousCheckpointPath, previousSignaturePath, nextCheckpointPath, nextSignaturePath string) error + VerifyEvidence(artifactRoot, checkpointPath, signaturePath string) (EvidenceVerification, error) +} + +type EvidenceVerification struct { + CeremonyID string + Sequence uint64 + Digest string + TransitionKind string + FullyVerified bool +} + +// ObjectStore is the strict subset needed for synchronization. Implementations +// must pin a versioned root read and bound every download. +type ObjectStore interface { + GetVersionedAtMost(key, localPath string, maximum int64) (store.ObjectVersion, error) +} + +// HighWater is durable, workspace-local rollback/fork state. +type HighWater interface { + Seen() (state.CheckpointPosition, bool, error) + Check(state.CheckpointPosition) error + Record(state.CheckpointPosition) error +} + +type Snapshot struct { + Root state.Root + RootVersion store.ObjectVersion + Checkpoint Checkpoint + Checked int +} + +type verifiedFile struct { + checkpointPath string + signaturePath string + checkpoint Checkpoint +} + +type fetchedNames map[string]state.ContentRef + +// Sync authenticates the root's checkpoint and every missing ancestor before +// it advances local high-water state. A failed high-water write means the +// caller receives no mutation-capable snapshot. +func Sync(objects ObjectStore, verifier Verifier, highWater HighWater, ceremonyID, tempParent string) (Snapshot, error) { + if objects == nil || verifier == nil || highWater == nil { + return Snapshot{}, errors.New("storage, verifier, and high-water store are required") + } + temp, err := os.MkdirTemp(tempParent, "relay-checkpoint-sync-") + if err != nil { + return Snapshot{}, err + } + defer os.RemoveAll(temp) + + rootPath := filepath.Join(temp, "root.json") + rootVersion, err := objects.GetVersionedAtMost(state.RootKey(ceremonyID), rootPath, maxRootBytes) + if err != nil { + return Snapshot{}, fmt.Errorf("fetch discovery root: %w", err) + } + raw, err := os.ReadFile(rootPath) + if err != nil { + return Snapshot{}, err + } + root, err := state.DecodeRoot(raw) + if err != nil { + return Snapshot{}, err + } + if root.CeremonyID != ceremonyID { + return Snapshot{}, errors.New("discovery root belongs to another ceremony") + } + + seen, haveSeen, err := highWater.Seen() + if err != nil { + return Snapshot{}, fmt.Errorf("read checkpoint high-water: %w", err) + } + + ref := SignedRef{Checkpoint: root.Checkpoint, Signature: root.CheckpointSignature} + var backwards []verifiedFile + names := make(fetchedNames) + seenIndex := -1 + for len(backwards) < maxCheckpointCount { + entry, err := fetchAndVerify(objects, verifier, temp, ref, names) + if err != nil { + return Snapshot{}, err + } + backwards = append(backwards, entry) + if haveSeen && entry.checkpoint.Position.Digest == seen.Digest { + if err := highWater.Check(entry.checkpoint.Position); err != nil { + return Snapshot{}, err + } + seenIndex = len(backwards) - 1 + } + if entry.checkpoint.Position.Sequence == 0 { + if entry.checkpoint.Previous != nil { + return Snapshot{}, errors.New("initial checkpoint unexpectedly names a predecessor") + } + break + } + if entry.checkpoint.Previous == nil { + return Snapshot{}, errors.New("checkpoint ancestry is incomplete") + } + ref = *entry.checkpoint.Previous + } + if len(backwards) == maxCheckpointCount { + return Snapshot{}, errors.New("checkpoint ancestry exceeds the synchronization limit") + } + oldest := backwards[len(backwards)-1] + if haveSeen && seenIndex < 0 { + return Snapshot{}, errors.New("published checkpoint does not descend from this workspace's authenticated high-water mark") + } + if !haveSeen && oldest.checkpoint.Position.Sequence != 0 { + return Snapshot{}, errors.New("fresh workspace could not authenticate checkpoint ancestry back to initialization") + } + + // The latest checkpoint carries the append-only inventory needed to + // reconstruct every accepted cp0-cp3 edge. Download it under each signed + // logical name, then let proof-tool re-derive the complete ancestry before + // changing durable local trust state. + latestFile := backwards[0] + if len(latestFile.checkpoint.Artifacts) == 0 || len(latestFile.checkpoint.Artifacts) > maxAcceptedArtifacts { + return Snapshot{}, errors.New("checkpoint accepted-artifact inventory is empty or exceeds the synchronization limit") + } + for _, artifact := range latestFile.checkpoint.Artifacts { + if _, err := fetchNamed(objects, temp, artifact, names); err != nil { + return Snapshot{}, fmt.Errorf("fetch accepted checkpoint evidence %q: %w", artifact.Name, err) + } + } + + // Structural edge validation gives focused diagnostics. Full evidence + // verification below is the authority for advancing the high-water mark. + for index := len(backwards) - 1; index >= 0; index-- { + current := backwards[index] + if index < len(backwards)-1 { + previous := backwards[index+1] + if err := verifier.VerifyTransition(previous.checkpointPath, previous.signaturePath, current.checkpointPath, current.signaturePath); err != nil { + return Snapshot{}, fmt.Errorf("verify checkpoint transition: %w", err) + } + } + } + evidence, err := verifier.VerifyEvidence(temp, latestFile.checkpointPath, latestFile.signaturePath) + if err != nil { + return Snapshot{}, fmt.Errorf("fully verify checkpoint evidence: %w", err) + } + if !evidence.FullyVerified || evidence.CeremonyID != ceremonyID || + evidence.Sequence != latestFile.checkpoint.Position.Sequence || evidence.Digest != latestFile.checkpoint.Position.Digest || + evidence.TransitionKind != latestFile.checkpoint.Transition { + return Snapshot{}, errors.New("full evidence verification does not match the discovered checkpoint") + } + + // A fresh workspace records cp0..latest. A returning workspace records only + // the children after its already-authenticated checkpoint. + start := len(backwards) - 1 + if haveSeen { + start = seenIndex - 1 + } + for index := start; index >= 0; index-- { + if err := highWater.Record(backwards[index].checkpoint.Position); err != nil { + return Snapshot{}, fmt.Errorf("persist checkpoint high-water: %w", err) + } + } + + latest := latestFile.checkpoint + latest.authenticatedEvidence = true + return Snapshot{Root: root, RootVersion: rootVersion, Checkpoint: latest, Checked: len(backwards)}, nil +} + +func fetchAndVerify(objects ObjectStore, verifier Verifier, temp string, ref SignedRef, names fetchedNames) (verifiedFile, error) { + checkpointPath, err := fetchNamed(objects, temp, ref.Checkpoint, names) + if err != nil { + return verifiedFile{}, fmt.Errorf("fetch checkpoint: %w", err) + } + signaturePath, err := fetchNamed(objects, temp, ref.Signature, names) + if err != nil { + return verifiedFile{}, fmt.Errorf("fetch checkpoint signature: %w", err) + } + checkpoint, err := verifier.VerifyCheckpoint(checkpointPath, signaturePath) + if err != nil { + return verifiedFile{}, fmt.Errorf("authenticate checkpoint: %w", err) + } + if checkpoint.Position.Digest != ref.Checkpoint.SHA256 { + return verifiedFile{}, errors.New("proof-tool checkpoint digest does not match the fetched reference") + } + return verifiedFile{checkpointPath: checkpointPath, signaturePath: signaturePath, checkpoint: checkpoint}, nil +} + +func fetchNamed(objects ObjectStore, root string, ref state.ContentRef, names fetchedNames) (string, error) { + localName := filepath.FromSlash(ref.Name) + if ref.Name == "" || filepath.IsAbs(localName) || filepath.Clean(localName) != localName || + localName == "." || localName == ".." || strings.HasPrefix(ref.Name, "../") || strings.Contains(ref.Name, `\`) { + return "", errors.New("immutable reference has an unsafe logical name") + } + if previous, exists := names[ref.Name]; exists { + if previous.SHA256 != ref.SHA256 || previous.Size != ref.Size { + return "", errors.New("one logical artifact name refers to different immutable bytes") + } + return filepath.Join(root, localName), nil + } + localPath := filepath.Join(root, localName) + if err := fetchExact(objects, ref, localPath); err != nil { + return "", err + } + names[ref.Name] = ref + return localPath, nil +} + +func fetchExact(objects ObjectStore, ref state.ContentRef, localPath string) error { + if ref.Size <= 0 || !validDigest(ref.SHA256) { + return errors.New("immutable reference requires a positive size and valid SHA-256") + } + version, err := objects.GetVersionedAtMost(store.Key(ref.SHA256), localPath, ref.Size) + if err != nil { + return err + } + if version.Size != ref.Size { + return fmt.Errorf("downloaded size %d, want %d", version.Size, ref.Size) + } + if err := verifyLocalRef(ref, localPath); err != nil { + _ = os.Remove(localPath) + return fmt.Errorf("downloaded object does not match its immutable reference: %w", err) + } + return nil +} diff --git a/internal/storagefirst/sync_test.go b/internal/storagefirst/sync_test.go new file mode 100644 index 0000000..4df3295 --- /dev/null +++ b/internal/storagefirst/sync_test.go @@ -0,0 +1,225 @@ +package storagefirst + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/store" +) + +type memoryObjects map[string][]byte + +func (m memoryObjects) GetVersionedAtMost(key, local string, maximum int64) (store.ObjectVersion, error) { + raw, ok := m[key] + if !ok { + return store.ObjectVersion{}, os.ErrNotExist + } + if int64(len(raw)) > maximum { + return store.ObjectVersion{}, errors.New("too large") + } + if err := os.MkdirAll(filepath.Dir(local), 0o700); err != nil { + return store.ObjectVersion{}, err + } + if err := os.WriteFile(local, raw, 0o600); err != nil { + return store.ObjectVersion{}, err + } + return store.ObjectVersion{ETag: "etag", Size: int64(len(raw))}, nil +} + +type verifierFake struct { + byDigest map[string]Checkpoint + transitions int + rejectEdge bool + rejectEvidence bool + evidence EvidenceVerification +} + +func (v *verifierFake) VerifyCheckpoint(checkpointPath, _ string) (Checkpoint, error) { + raw, err := os.ReadFile(checkpointPath) + if err != nil { + return Checkpoint{}, err + } + digest := sum(raw) + checkpoint, ok := v.byDigest[digest] + if !ok { + return Checkpoint{}, errors.New("unknown checkpoint") + } + return checkpoint, nil +} + +func (v *verifierFake) VerifyTransition(_, _, _, _ string) error { + v.transitions++ + if v.rejectEdge { + return errors.New("illegal edge") + } + return nil +} + +func (v *verifierFake) VerifyEvidence(_, _, _ string) (EvidenceVerification, error) { + if v.rejectEvidence { + return EvidenceVerification{}, errors.New("bad stored evidence") + } + return v.evidence, nil +} + +type highWaterFake struct { + position state.CheckpointPosition + exists bool + fail bool +} + +func (h *highWaterFake) Seen() (state.CheckpointPosition, bool, error) { + return h.position, h.exists, nil +} +func (h *highWaterFake) Check(candidate state.CheckpointPosition) error { + if h.exists && candidate.Sequence == h.position.Sequence && candidate.Digest != h.position.Digest { + return errors.New("fork") + } + return nil +} +func (h *highWaterFake) Record(candidate state.CheckpointPosition) error { + if h.fail { + return errors.New("disk full") + } + h.position, h.exists = candidate, true + return nil +} + +func sum(raw []byte) string { + hash := sha256.Sum256(raw) + return "sha256:" + hex.EncodeToString(hash[:]) +} + +func ref(name string, raw []byte) state.ContentRef { + return state.ContentRef{Name: name, SHA256: sum(raw), Size: int64(len(raw))} +} + +func fixture(t *testing.T) (memoryObjects, *verifierFake, string) { + t.Helper() + ceremonyID := "sha256:" + strings.Repeat("1", 64) + cp0, sig0 := []byte(`{"sequence":0}`), []byte("sig0") + cp1, sig1 := []byte(`{"sequence":1}`), []byte("sig1") + evidence := []byte("accepted evidence") + evidenceRef := ref("phase1/evidence.bin", evidence) + r0 := SignedRef{ref("checkpoints/0.json", cp0), ref("checkpoints/0.sig", sig0)} + r1 := SignedRef{ref("checkpoints/1.json", cp1), ref("checkpoints/1.sig", sig1)} + p0 := state.CheckpointPosition{Sequence: 0, Digest: r0.Checkpoint.SHA256, PhaseHeads: map[string]state.PhaseHeadPosition{"phase1": {Digest: "sha256:" + strings.Repeat("a", 64)}}} + p1 := state.CheckpointPosition{Sequence: 1, Digest: r1.Checkpoint.SHA256, PreviousDigest: p0.Digest, PhaseHeads: p0.PhaseHeads} + root := state.Root{Schema: state.RootSchema, CeremonyID: ceremonyID, Checkpoint: r1.Checkpoint, CheckpointSignature: r1.Signature} + rootRaw, err := root.Encode() + if err != nil { + t.Fatal(err) + } + objects := memoryObjects{ + state.RootKey(ceremonyID): rootRaw, + store.Key(r0.Checkpoint.SHA256): cp0, store.Key(r0.Signature.SHA256): sig0, + store.Key(r1.Checkpoint.SHA256): cp1, store.Key(r1.Signature.SHA256): sig1, + store.Key(evidenceRef.SHA256): evidence, + } + verifier := &verifierFake{byDigest: map[string]Checkpoint{ + p0.Digest: {Position: p0, Artifacts: []state.ContentRef{evidenceRef}}, + p1.Digest: {Position: p1, Previous: &r0, Transition: "phase1-outbound-published", Artifacts: []state.ContentRef{evidenceRef}}, + }, evidence: EvidenceVerification{CeremonyID: ceremonyID, Sequence: 1, Digest: p1.Digest, TransitionKind: "phase1-outbound-published", FullyVerified: true}} + return objects, verifier, ceremonyID +} + +func TestSyncRejectsIncompleteEvidenceBeforeAdvancingHighWater(t *testing.T) { + objects, verifier, ceremonyID := fixture(t) + verifier.rejectEvidence = true + highWater := &highWaterFake{} + _, err := Sync(objects, verifier, highWater, ceremonyID, t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "bad stored evidence") { + t.Fatalf("err=%v", err) + } + if highWater.exists { + t.Fatal("unverified evidence advanced high-water") + } +} + +func TestSyncReturningWorkspaceRecordsOnlyNewChildren(t *testing.T) { + objects, verifier, ceremonyID := fixture(t) + var p0 state.CheckpointPosition + for _, checkpoint := range verifier.byDigest { + if checkpoint.Position.Sequence == 0 { + p0 = checkpoint.Position + } + } + highWater := &highWaterFake{position: p0, exists: true} + if _, err := Sync(objects, verifier, highWater, ceremonyID, t.TempDir()); err != nil { + t.Fatal(err) + } + if highWater.position.Sequence != 1 { + t.Fatalf("high-water sequence=%d, want 1", highWater.position.Sequence) + } +} + +func TestSyncFreshWorkspaceWalksAndRecordsAllAncestry(t *testing.T) { + objects, verifier, ceremonyID := fixture(t) + highWater := &highWaterFake{} + snapshot, err := Sync(objects, verifier, highWater, ceremonyID, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if snapshot.Checkpoint.Position.Sequence != 1 || snapshot.Checked != 2 || verifier.transitions != 1 { + t.Fatalf("snapshot=%+v transitions=%d", snapshot, verifier.transitions) + } + if !highWater.exists || highWater.position.Digest != snapshot.Checkpoint.Position.Digest { + t.Fatal("latest authenticated checkpoint was not recorded") + } +} + +func TestSyncRejectsIllegalTransitionBeforeAdvancingLatest(t *testing.T) { + objects, verifier, ceremonyID := fixture(t) + verifier.rejectEdge = true + highWater := &highWaterFake{} + _, err := Sync(objects, verifier, highWater, ceremonyID, t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "illegal edge") { + t.Fatalf("err=%v", err) + } + if highWater.position.Sequence != 0 { + t.Fatal("illegal child advanced high-water") + } +} + +func TestSyncRefusesMutationCapableResultWhenHighWaterCannotPersist(t *testing.T) { + objects, verifier, ceremonyID := fixture(t) + _, err := Sync(objects, verifier, &highWaterFake{fail: true}, ceremonyID, t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "disk full") { + t.Fatalf("err=%v", err) + } +} + +func TestSyncRejectsRootForAnotherCeremony(t *testing.T) { + objects, verifier, ceremonyID := fixture(t) + rootRaw := objects[state.RootKey(ceremonyID)] + var root state.Root + if err := json.Unmarshal(rootRaw, &root); err != nil { + t.Fatal(err) + } + root.CeremonyID = "sha256:" + strings.Repeat("2", 64) + changed, _ := json.Marshal(root) + objects[state.RootKey(ceremonyID)] = changed + if _, err := Sync(objects, verifier, &highWaterFake{}, ceremonyID, t.TempDir()); err == nil { + t.Fatal("cross-ceremony root accepted") + } +} + +func TestSyncRejectsCheckpointDigestMismatch(t *testing.T) { + objects, verifier, ceremonyID := fixture(t) + var root state.Root + _ = json.Unmarshal(objects[state.RootKey(ceremonyID)], &root) + objects[store.Key(root.Checkpoint.SHA256)] = []byte("different bytes") + root.Checkpoint.Size = int64(len("different bytes")) + rootRaw, _ := root.Encode() + objects[state.RootKey(ceremonyID)] = rootRaw + if _, err := Sync(objects, verifier, &highWaterFake{}, ceremonyID, t.TempDir()); err == nil { + t.Fatal("wrong checkpoint bytes accepted") + } +} diff --git a/internal/storagefirst/sync_v4.go b/internal/storagefirst/sync_v4.go new file mode 100644 index 0000000..9d902ad --- /dev/null +++ b/internal/storagefirst/sync_v4.go @@ -0,0 +1,416 @@ +package storagefirst + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +// VerifierV4 is implemented by the approved-tool Inspector, not by parsing +// downloaded checkpoint JSON in Relay. Discovery must not authorize actions. +type VerifierV4 interface { + DiscoverCheckpointV4(root, record, signature string) (transcript.CheckpointDiscoveryV4, error) + CheckpointGuidanceV4(root, record, signature string) (transcript.CheckpointInspectionV4, transcript.EnrollmentMetadataInspectionV4, error) +} + +// SnapshotV4 exists only after complete signed structural ancestry and committed +// enrollment verification. It proves neither payload availability, contribution +// replay, complete enrollment collection nor production approval. +// Its immutable state cannot be edited through returned slice/pointer aliases. +type SnapshotV4 struct { + root state.Root + version store.ObjectVersion + head transcript.SignedArtifactRefs + files []state.ContentRef + structural []state.ContentRef + inspection []byte + commitments []byte + enrollments []byte + checked int + history []string +} + +func (s SnapshotV4) Root() (state.Root, store.ObjectVersion) { return s.root, s.version } +func (s SnapshotV4) Head() transcript.SignedArtifactRefs { return s.head } +func (s SnapshotV4) Files() []state.ContentRef { return append([]state.ContentRef(nil), s.files...) } +func (s SnapshotV4) StructuralFiles() []state.ContentRef { + return append([]state.ContentRef(nil), s.structural...) +} +func (s SnapshotV4) Checked() int { return s.checked } +func (s SnapshotV4) ContainsCheckpointDigest(digest string) bool { + return slices.Contains(s.history, digest) +} +func (s SnapshotV4) State() (transcript.CheckpointStateV4, error) { + if len(s.inspection) == 0 { + return transcript.CheckpointStateV4{}, errors.New("no verified V4 snapshot") + } + var c transcript.CheckpointStateV4 + if err := json.Unmarshal(s.inspection, &c); err != nil { + return c, err + } + return c, nil +} + +func (s SnapshotV4) Commitments() (transcript.CheckpointCommitmentsV4, error) { + var c transcript.CheckpointCommitmentsV4 + if len(s.commitments) == 0 { + return c, errors.New("no verified V4 commitments") + } + err := json.Unmarshal(s.commitments, &c) + return c, err +} + +// Enrollment signatures and exact head binding were checked, not disclosure +// contents, roster completeness, independent people or contribution mathematics. +func (s SnapshotV4) Enrollments() (transcript.EnrollmentMetadataV4, error) { + var e transcript.EnrollmentMetadataV4 + if len(s.enrollments) == 0 { + return e, errors.New("no verified V4 enrollment metadata") + } + err := json.Unmarshal(s.enrollments, &e) + return e, err +} + +// SyncV4 follows signed discovery references, stages the metadata needed for +// structure and enrollment guidance, and verifies the full ancestry once. Callers +// hold their workspace lock across sync and any subsequent mutation intent. +// Existing V1–V3 ceremonies continue using Sync and their original semantics. +func SyncV4(objects ObjectStore, verifier VerifierV4, highWater HighWater, ceremonyID, tempParent string) (SnapshotV4, error) { + return syncV4(objects, verifier, highWater, ceremonyID, tempParent, "") +} + +// SyncV4Retained authenticates the same complete history as SyncV4 and then +// retains every verified public artifact at its signed logical path. Existing +// identical files make retries harmless; a different file at the same path is +// a conflict. High-water advances only after retention succeeds. +func SyncV4Retained(objects ObjectStore, verifier VerifierV4, highWater HighWater, ceremonyID, tempParent, retainedRoot string) (SnapshotV4, error) { + if !filepath.IsAbs(retainedRoot) || filepath.Clean(retainedRoot) != retainedRoot { + return SnapshotV4{}, errors.New("retained V4 artifact root must be an absolute clean path") + } + return syncV4(objects, verifier, highWater, ceremonyID, tempParent, retainedRoot) +} + +func syncV4(objects ObjectStore, verifier VerifierV4, highWater HighWater, ceremonyID, tempParent, retainedRoot string) (SnapshotV4, error) { + if objects == nil || verifier == nil || highWater == nil || !validDigest(ceremonyID) { + return SnapshotV4{}, errors.New("storage, V4 verifier, high-water and ceremony identity are required") + } + temp, err := os.MkdirTemp(tempParent, "relay-v4-sync-") + if err != nil { + return SnapshotV4{}, err + } + defer os.RemoveAll(temp) + stage := filepath.Join(temp, "artifacts") + if err := os.Mkdir(stage, 0700); err != nil { + return SnapshotV4{}, err + } + rootPath := filepath.Join(temp, "discovery.json") + version, err := objects.GetVersionedAtMost(state.RootKey(ceremonyID), rootPath, maxRootBytes) + if err != nil { + return SnapshotV4{}, fmt.Errorf("read ceremony progress: %w", err) + } + raw, err := os.ReadFile(rootPath) + if err != nil { + return SnapshotV4{}, err + } + root, err := state.DecodeRoot(raw) + if err != nil { + return SnapshotV4{}, err + } + if root.CeremonyID != ceremonyID { + return SnapshotV4{}, errors.New("progress belongs to another ceremony") + } + seen, haveSeen, err := highWater.Seen() + if err != nil { + return SnapshotV4{}, err + } + refs := SignedRef{Checkpoint: root.Checkpoint, Signature: root.CheckpointSignature} + names := fetchedNames{} + visited := map[string]bool{} + enrollmentRefs := map[transcript.SignedArtifactRefs]bool{} + var backwards []state.CheckpointPosition + seenIndex := -1 + var headPath, headSig string + var discoveredPrevious *transcript.SignedArtifactRefs + for { + if len(backwards) >= transcript.MaxCheckpointSequenceV4+1 { + return SnapshotV4{}, errors.New("checkpoint history exceeds V4 protocol limit") + } + if refs.Checkpoint.Size > 16<<20 || refs.Signature.Size > 4096 { + return SnapshotV4{}, errors.New("checkpoint pair exceeds verification size limit") + } + if visited[refs.Checkpoint.SHA256] { + return SnapshotV4{}, errors.New("checkpoint history contains a cycle") + } + visited[refs.Checkpoint.SHA256] = true + record, err := fetchNamed(objects, stage, refs.Checkpoint, names) + if err != nil { + return SnapshotV4{}, err + } + signature, err := fetchNamed(objects, stage, refs.Signature, names) + if err != nil { + return SnapshotV4{}, err + } + discovery, err := verifier.DiscoverCheckpointV4(stage, record, signature) + if err != nil { + return SnapshotV4{}, fmt.Errorf("discover signed history: %w", err) + } + d := discovery.Discovery + if discovery.Schema != "proof-tool-mpc-checkpoint-discovery-v4" || discovery.Depth != "signed-checkpoint-discovery" || d.CeremonyID != ceremonyID || d.Sequence > transcript.MaxCheckpointSequenceV4 || discovery.AncestryVerified || discovery.ArtifactsVerified || discovery.MathematicsReplayed || discovery.GlobalFreshnessVerified || contentRef(discovery.CheckpointRefs.Record) != refs.Checkpoint || contentRef(discovery.CheckpointRefs.Signature) != refs.Signature { + return SnapshotV4{}, errors.New("discovery does not match the exact fetched checkpoint") + } + if len(backwards) == 0 { + headPath, headSig = record, signature + discoveredPrevious = d.PreviousCheckpoint + } else if d.Sequence+1 != backwards[len(backwards)-1].Sequence { + return SnapshotV4{}, errors.New("checkpoint history skips a sequence") + } + p := state.CheckpointPosition{Sequence: d.Sequence, Digest: refs.Checkpoint.SHA256} + if d.PreviousCheckpoint != nil { + p.PreviousDigest = d.PreviousCheckpoint.Record.Digest.SHA256 + } + backwards = append(backwards, p) + if haveSeen && p.Digest == seen.Digest { + if err := highWater.Check(p); err != nil { + return SnapshotV4{}, err + } + seenIndex = len(backwards) - 1 + } + if d.VerificationDependencies == nil || len(d.VerificationDependencies) > 5 { + return SnapshotV4{}, errors.New("invalid V4 discovery dependencies") + } + for _, dependency := range d.VerificationDependencies { + if dependency.Digest.Size > 16<<20 { + return SnapshotV4{}, errors.New("discovery dependency exceeds metadata limit") + } + if _, err := fetchNamed(objects, stage, contentRef(dependency), names); err != nil { + return SnapshotV4{}, fmt.Errorf("fetch history verification record: %w", err) + } + } + if pair := d.Enrollment; pair != nil { + if len(enrollmentRefs) >= 128 || enrollmentRefs[*pair] || pair.Record.Digest.Size > 16<<20 || pair.Signature.Digest.Size > 4096 { + return SnapshotV4{}, errors.New("invalid enrollment discovery set") + } + enrollmentRefs[*pair] = true + for _, ref := range []transcript.ArtifactRef{pair.Record, pair.Signature} { + if _, err := fetchNamed(objects, stage, contentRef(ref), names); err != nil { + return SnapshotV4{}, fmt.Errorf("fetch enrollment guidance record: %w", err) + } + } + } + if d.Sequence == 0 { + if d.PreviousCheckpoint != nil { + return SnapshotV4{}, errors.New("initial checkpoint names an ancestor") + } + break + } + if d.PreviousCheckpoint == nil { + return SnapshotV4{}, errors.New("incomplete checkpoint history") + } + refs = SignedRef{Checkpoint: contentRef(d.PreviousCheckpoint.Record), Signature: contentRef(d.PreviousCheckpoint.Signature)} + } + if haveSeen && seenIndex < 0 { + return SnapshotV4{}, errors.New("progress does not descend from this workspace's verified history") + } + verified, metadata, err := verifier.CheckpointGuidanceV4(stage, headPath, headSig) + if err != nil { + return SnapshotV4{}, fmt.Errorf("verify complete ceremony history: %w", err) + } + c := verified.Checkpoint + if c.Schema != "proof-tool-mpc-checkpoint-v4" || c.Workflow != "storage-first-v2" || c.ReleaseVerification != "coordinator-full-replay-v1" { + return SnapshotV4{}, errors.New("verified history has an incompatible protocol") + } + if (c.PreviousCheckpoint == nil) != (discoveredPrevious == nil) || (c.PreviousCheckpoint != nil && *c.PreviousCheckpoint != *discoveredPrevious) { + return SnapshotV4{}, errors.New("verified predecessor differs from discovery") + } + if verified.Schema != "proof-tool-mpc-checkpoint-inspection-v4" || verified.Depth != "checkpoint-structure" || verified.ArtifactsVerified || verified.MathematicsReplayed || verified.GlobalFreshnessVerified || c.CeremonyID != ceremonyID || c.Sequence != backwards[0].Sequence || contentRef(verified.CheckpointRefs.Record) != root.Checkpoint || contentRef(verified.CheckpointRefs.Signature) != root.CheckpointSignature { + return SnapshotV4{}, errors.New("verified history differs from discovered head") + } + structural := make([]state.ContentRef, 0, len(names)) + for _, ref := range names { + structural = append(structural, ref) + } + slices.SortFunc(structural, func(a, b state.ContentRef) int { return strings.Compare(a.Name, b.Name) }) + public, err := transcript.RequiredPublicArtifactsV4(verified) + if err != nil { + return SnapshotV4{}, fmt.Errorf("derive required public artifacts: %w", err) + } + for _, ref := range public { + if _, err := fetchNamed(objects, stage, contentRef(ref), names); err != nil { + return SnapshotV4{}, fmt.Errorf("fetch required public artifact %q: %w", ref.Name, err) + } + } + encoded, err := json.Marshal(c) + if err != nil { + return SnapshotV4{}, err + } + start := len(backwards) - 1 + if haveSeen { + start = seenIndex - 1 + } + if verified.Commitments.Enrollments == nil || len(verified.Commitments.Enrollments) > 128 || verified.Commitments.Turns == nil || len(verified.Commitments.Turns) > 40 { + return SnapshotV4{}, errors.New("missing or oversized checkpoint commitments") + } + if len(verified.Commitments.Enrollments) != len(enrollmentRefs) { + return SnapshotV4{}, errors.New("enrollment discovery differs from verified set") + } + for _, pair := range verified.Commitments.Enrollments { + if !enrollmentRefs[pair] { + return SnapshotV4{}, errors.New("verified enrollment was not discovered") + } + } + e := metadata.Metadata + if metadata.Schema != "proof-tool-mpc-enrollment-metadata-v4" || metadata.Depth != "committed-enrollment-signatures" || !metadata.EnrollmentSignaturesVerified || metadata.DisclosureContentsVerified || metadata.CompleteRosterVerified || metadata.GlobalFreshnessVerified || e.CeremonyID != ceremonyID || e.Checkpoint != verified.CheckpointRefs || e.Enrollments == nil || len(e.Enrollments) != len(verified.Commitments.Enrollments) { + return SnapshotV4{}, errors.New("enrollment metadata differs from verified head or boundary") + } + for n, enrollment := range e.Enrollments { + if enrollment.Refs != verified.Commitments.Enrollments[n] { + return SnapshotV4{}, errors.New("enrollment metadata is not the exact committed set") + } + } + commitments, err := json.Marshal(verified.Commitments) + if err != nil { + return SnapshotV4{}, err + } + enrollments, err := json.Marshal(e) + if err != nil { + return SnapshotV4{}, err + } + if retainedRoot != "" { + if err := retainVerifiedV4Artifacts(stage, retainedRoot, names); err != nil { + return SnapshotV4{}, fmt.Errorf("retain verified public ceremony files: %w", err) + } + } + // One complete verification passed. A partial persistence failure is safe + // to resume; incomplete metadata never advances this workspace's position. + for n := start; n >= 0; n-- { + if err := highWater.Record(backwards[n]); err != nil { + return SnapshotV4{}, fmt.Errorf("save verified progress: %w", err) + } + } + files := make([]state.ContentRef, 0, len(names)) + for _, ref := range names { + files = append(files, ref) + } + slices.SortFunc(files, func(a, b state.ContentRef) int { return strings.Compare(a.Name, b.Name) }) + history := make([]string, 0, len(backwards)) + for _, position := range backwards { + history = append(history, position.Digest) + } + return SnapshotV4{root: root, version: version, head: verified.CheckpointRefs, files: files, structural: structural, inspection: encoded, commitments: commitments, enrollments: enrollments, checked: len(backwards), history: history}, nil +} + +func retainVerifiedV4Artifacts(stage, destination string, names fetchedNames) error { + if len(names) == 0 { + return errors.New("verified artifact set is empty") + } + if err := ensureRetainedDirectory(destination, destination, "."); err != nil { + return err + } + logical := make([]string, 0, len(names)) + for name := range names { + logical = append(logical, name) + } + slices.Sort(logical) + for _, name := range logical { + ref := names[name] + relative := filepath.FromSlash(name) + if err := ensureRetainedDirectory(destination, filepath.Dir(filepath.Join(destination, relative)), filepath.Dir(relative)); err != nil { + return err + } + source := filepath.Join(stage, relative) + target := filepath.Join(destination, relative) + if _, err := os.Lstat(target); err == nil { + if err := verifyLocalRef(ref, target); err != nil { + return fmt.Errorf("existing %q differs from verified storage bytes", name) + } + continue + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + temp, err := os.CreateTemp(filepath.Dir(target), ".relay-v4-artifact-") + if err != nil { + return err + } + tempPath := temp.Name() + ok := false + defer func() { + if !ok { + _ = os.Remove(tempPath) + } + }() + input, err := os.Open(source) + if err != nil { + temp.Close() + return err + } + _, copyErr := io.Copy(temp, input) + closeInputErr := input.Close() + syncErr := temp.Sync() + closeErr := temp.Close() + if copyErr != nil || closeInputErr != nil || syncErr != nil || closeErr != nil { + return errors.Join(copyErr, closeInputErr, syncErr, closeErr) + } + if err := verifyLocalRef(ref, tempPath); err != nil { + return err + } + if err := os.Link(tempPath, target); err != nil { + if _, statErr := os.Lstat(target); statErr == nil { + if verifyErr := verifyLocalRef(ref, target); verifyErr == nil { + ok = true + _ = os.Remove(tempPath) + continue + } + } + return err + } + if err := os.Remove(tempPath); err != nil { + return err + } + ok = true + } + return nil +} + +func ensureRetainedDirectory(root, target, relative string) error { + if !filepath.IsAbs(root) || filepath.Clean(root) != root || !filepath.IsAbs(target) || filepath.Clean(target) != target { + return errors.New("retained artifact directory must use absolute clean paths") + } + within, err := filepath.Rel(root, target) + if err != nil || within == ".." || filepath.IsAbs(within) || strings.HasPrefix(within, ".."+string(filepath.Separator)) { + return fmt.Errorf("retained artifact directory %q escapes its root", relative) + } + current := root + if info, err := os.Lstat(current); errors.Is(err, os.ErrNotExist) { + if err := os.Mkdir(current, 0700); err != nil { + return err + } + } else if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("retained artifact root must be a real directory") + } + if within == "." { + return nil + } + for _, component := range strings.Split(within, string(filepath.Separator)) { + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + if err := os.Mkdir(current, 0700); err != nil { + return err + } + continue + } + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("retained artifact parent %q is not a real directory", relative) + } + } + return nil +} diff --git a/internal/storagefirst/sync_v4_metadata_test.go b/internal/storagefirst/sync_v4_metadata_test.go new file mode 100644 index 0000000..dc5ec41 --- /dev/null +++ b/internal/storagefirst/sync_v4_metadata_test.go @@ -0,0 +1,99 @@ +package storagefirst + +import ( + "errors" + "testing" + + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +func TestSyncV4EnrollmentMetadataIsExactAndRequired(t *testing.T) { + for _, variant := range []string{"ok", "missing-record", "missing-signature", "bad-signature", "wrong-head", "wrong-ceremony", "subset", "different-ref", "overclaim", "nil-commitments"} { + t.Run(variant, func(t *testing.T) { + objects, v, id := syncFixtureV4(t, 1) + artifact := func(name, body string) transcript.ArtifactRef { + b := []byte(body) + objects[store.Key(sum(b))] = b + return transcript.ArtifactRef{Name: name, Digest: transcript.Digest{SHA256: sum(b), Blake2b256: "blake2b256:" + sum(b)[7:], Size: int64(len(b))}} + } + pair := transcript.SignedArtifactRefs{Record: artifact("enrollments/one.json", "public enrollment"), Signature: artifact("enrollments/one.sig", "signature")} + v.full.Commitments.Enrollments = []transcript.SignedArtifactRefs{pair} + discovery := v.discoveries[v.full.CheckpointRefs.Record.Digest.SHA256] + discovery.Discovery.Enrollment = &pair + v.discoveries[v.full.CheckpointRefs.Record.Digest.SHA256] = discovery + v.metadata = &transcript.EnrollmentMetadataInspectionV4{Schema: "proof-tool-mpc-enrollment-metadata-v4", Depth: "committed-enrollment-signatures", EnrollmentSignaturesVerified: true, Metadata: transcript.EnrollmentMetadataV4{CeremonyID: id, Checkpoint: v.full.CheckpointRefs, Enrollments: []transcript.CommittedEnrollmentMetadataV4{{Refs: pair}}}} + switch variant { + case "missing-record": + delete(objects, store.Key(pair.Record.Digest.SHA256)) + case "missing-signature": + delete(objects, store.Key(pair.Signature.Digest.SHA256)) + case "bad-signature": + v.metadataErr = errors.New("signature rejected") + case "wrong-head": + v.metadata.Metadata.Checkpoint.Signature.Digest.SHA256 = sum([]byte("other")) + case "wrong-ceremony": + v.metadata.Metadata.CeremonyID = sum([]byte("other")) + case "subset": + v.metadata.Metadata.Enrollments = []transcript.CommittedEnrollmentMetadataV4{} + case "different-ref": + v.metadata.Metadata.Enrollments[0].Refs.Record.Name = "loose.json" + case "overclaim": + v.metadata.CompleteRosterVerified = true + case "nil-commitments": + v.full.Commitments.Enrollments = nil + } + h := &highWaterFake{} + snapshot, err := SyncV4(objects, v, h, id, t.TempDir()) + if variant != "ok" { + if err == nil { + t.Fatal("incomplete metadata produced guidance snapshot") + } + if _, err := snapshot.State(); err == nil { + t.Fatal("failure exposed state") + } + if _, err := snapshot.Enrollments(); err == nil { + t.Fatal("failure exposed enrollments") + } + if h.exists { + t.Fatal("metadata failure advanced high-water") + } + return + } + if err != nil { + t.Fatal(err) + } + if v.metadataCalls != 1 || v.fullCalls != 1 { + t.Fatal("enrollments not batched") + } + commitments, err := snapshot.Commitments() + if err != nil { + t.Fatal(err) + } + commitments.Enrollments[0].Record.Name = "changed" + enrollments, err := snapshot.Enrollments() + if err != nil { + t.Fatal(err) + } + enrollments.Enrollments[0].Refs.Record.Name = "changed" + c, _ := snapshot.Commitments() + e, _ := snapshot.Enrollments() + if c.Enrollments[0] != pair || e.Enrollments[0].Refs != pair { + t.Fatal("snapshot facts mutable through caller alias") + } + }) + } +} + +func TestSyncV4MissingMetadataRetry(t *testing.T) { + objects, v, id := syncFixtureV4(t, 1) + h := &highWaterFake{} + v.metadataErr = errors.New("temporarily unavailable") + if _, err := SyncV4(objects, v, h, id, t.TempDir()); err == nil || h.exists { + t.Fatal("incomplete metadata advanced progress") + } + v.metadataErr = nil + if _, err := SyncV4(objects, v, h, id, t.TempDir()); err != nil || !h.exists || h.position.Sequence != 1 { + t.Fatal("corrected metadata did not resume", err) + } +} diff --git a/internal/storagefirst/sync_v4_test.go b/internal/storagefirst/sync_v4_test.go new file mode 100644 index 0000000..174d4f4 --- /dev/null +++ b/internal/storagefirst/sync_v4_test.go @@ -0,0 +1,351 @@ +package storagefirst + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/zksecurity/relay/internal/state" + "github.com/zksecurity/relay/internal/store" + "github.com/zksecurity/relay/internal/transcript" +) + +type verifierV4Fake struct { + discoveries map[string]transcript.CheckpointDiscoveryV4 + full transcript.CheckpointInspectionV4 + reject bool + fullCalls int + requiredDependency string + metadata *transcript.EnrollmentMetadataInspectionV4 + metadataErr error + metadataCalls int +} + +func (v *verifierV4Fake) CheckpointGuidanceV4(root, record, signature string) (transcript.CheckpointInspectionV4, transcript.EnrollmentMetadataInspectionV4, error) { + c, err := v.StoredCheckpointV4(root, record, signature) + if err != nil { + return transcript.CheckpointInspectionV4{}, transcript.EnrollmentMetadataInspectionV4{}, err + } + e, err := v.CheckpointEnrollmentsV4(root, record, signature) + return c, e, err +} + +func (v *verifierV4Fake) CheckpointEnrollmentsV4(root, _, _ string) (transcript.EnrollmentMetadataInspectionV4, error) { + v.metadataCalls++ + if v.metadataErr != nil { + return transcript.EnrollmentMetadataInspectionV4{}, v.metadataErr + } + for _, pair := range v.full.Commitments.Enrollments { + for _, ref := range []transcript.ArtifactRef{pair.Record, pair.Signature} { + if _, err := os.Stat(filepath.Join(root, ref.Name)); err != nil { + return transcript.EnrollmentMetadataInspectionV4{}, err + } + } + } + if v.metadata != nil { + return *v.metadata, nil + } + return transcript.EnrollmentMetadataInspectionV4{Schema: "proof-tool-mpc-enrollment-metadata-v4", Depth: "committed-enrollment-signatures", EnrollmentSignaturesVerified: true, Metadata: transcript.EnrollmentMetadataV4{CeremonyID: v.full.Checkpoint.CeremonyID, Checkpoint: v.full.CheckpointRefs, Enrollments: []transcript.CommittedEnrollmentMetadataV4{}}}, nil +} + +func (v *verifierV4Fake) DiscoverCheckpointV4(_, record, _ string) (transcript.CheckpointDiscoveryV4, error) { + b, err := os.ReadFile(record) + if err != nil { + return transcript.CheckpointDiscoveryV4{}, err + } + d, ok := v.discoveries[sum(b)] + if !ok { + return d, errors.New("unknown checkpoint") + } + return d, nil +} +func (v *verifierV4Fake) StoredCheckpointV4(root, _, _ string) (transcript.CheckpointInspectionV4, error) { + v.fullCalls++ + if v.requiredDependency != "" { + if _, err := os.Stat(filepath.Join(root, v.requiredDependency)); err != nil { + return transcript.CheckpointInspectionV4{}, err + } + } + if v.reject { + return transcript.CheckpointInspectionV4{}, errors.New("illegal signed ancestry") + } + return v.full, nil +} + +type interruptedHighWaterV4 struct { + HighWater + writes int + failAt int +} + +func (h *interruptedHighWaterV4) Record(p state.CheckpointPosition) error { + h.writes++ + if h.writes == h.failAt { + return errors.New("interrupted persistence") + } + return h.HighWater.Record(p) +} + +func TestSyncV4StagesDependenciesAndResumesPartialPersistence(t *testing.T) { + objects, verifier, id := syncFixtureV4(t, 3) + dependency := []byte("bounded governance record") + objects[store.Key(sum(dependency))] = dependency + d := verifier.discoveries[verifier.full.CheckpointRefs.Record.Digest.SHA256] + d.Discovery.VerificationDependencies = []transcript.ArtifactRef{{Name: "governance/record.json", Digest: transcript.Digest{SHA256: sum(dependency), Size: int64(len(dependency))}}} + verifier.discoveries[verifier.full.CheckpointRefs.Record.Digest.SHA256] = d + verifier.requiredDependency = "governance/record.json" + h, err := state.OpenWorkspaceHighWater(t.TempDir(), id) + if err != nil { + t.Fatal(err) + } + interrupted := &interruptedHighWaterV4{HighWater: h, failAt: 3} + if _, err := SyncV4(objects, verifier, interrupted, id, t.TempDir()); err == nil { + t.Fatal("persistence failure hidden") + } + position, exists, err := h.Seen() + if err != nil || !exists || position.Sequence != 1 { + t.Fatalf("partial progress lost: %+v %v", position, err) + } + verifier.fullCalls = 0 + if _, err := SyncV4(objects, verifier, h, id, t.TempDir()); err != nil { + t.Fatal(err) + } + position, _, _ = h.Seen() + if position.Sequence != 3 || verifier.fullCalls != 1 { + t.Fatal("resume did not verify and finish") + } +} + +func TestSyncV4RetainsVerifiedArtifactsAndRejectsConflicts(t *testing.T) { + objects, verifier, id := syncFixtureV4(t, 2) + dependency := []byte("current transcript payload") + objects[store.Key(sum(dependency))] = dependency + head := verifier.full.CheckpointRefs.Record.Digest.SHA256 + discovery := verifier.discoveries[head] + discovery.Discovery.VerificationDependencies = []transcript.ArtifactRef{{Name: "governance/record.json", Digest: transcript.Digest{SHA256: sum(dependency), Size: int64(len(dependency))}}} + verifier.discoveries[head] = discovery + verifier.requiredDependency = "governance/record.json" + root := filepath.Join(t.TempDir(), "public") + highWater := &highWaterFake{} + if _, err := SyncV4Retained(objects, verifier, highWater, id, t.TempDir(), root); err != nil { + t.Fatal(err) + } + for name, contents := range map[string][]byte{"checkpoints/0.json": []byte("checkpoint-0"), "checkpoints/2.sig": []byte("signature-2"), "governance/record.json": dependency, "phase1/genesis.bin": []byte("phase1 genesis")} { + got, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name))) + if err != nil || string(got) != string(contents) { + t.Fatalf("retained %s = %q, %v", name, got, err) + } + } + if _, err := SyncV4Retained(objects, verifier, highWater, id, t.TempDir(), root); err != nil { + t.Fatal("identical retention did not resume", err) + } + if err := os.WriteFile(filepath.Join(root, "phase1", "genesis.bin"), []byte("changed"), 0600); err != nil { + t.Fatal(err) + } + if _, err := SyncV4Retained(objects, verifier, highWater, id, t.TempDir(), root); err == nil { + t.Fatal("conflicting retained file accepted") + } +} + +func TestSyncV4FreshWorkspaceDownloadsCompleteFinalReleaseInventory(t *testing.T) { + objects, verifier, id := syncFixtureV4(t, 2) + artifact := func(name string, raw []byte) transcript.ArtifactRef { + objects[store.Key(sum(raw))] = raw + return transcript.ArtifactRef{Name: name, Digest: transcript.Digest{SHA256: sum(raw), Blake2b256: "blake2b256:" + sum(raw)[7:], Size: int64(len(raw))}} + } + release := transcript.SignedArtifactRefs{ + Record: artifact("final/release/release.json", []byte("signed final release")), + Signature: artifact("final/release/release.sig", []byte("final release signature")), + } + largeMember := artifact("final/release/ownership.pk", []byte("production-sized public key placeholder")) + verifier.full.Checkpoint.Progress.FinalRelease = &release + verifier.full.Commitments.FinalReleaseArtifacts = []transcript.ArtifactRef{largeMember, release.Record, release.Signature} + root := filepath.Join(t.TempDir(), "fresh-public") + if _, err := SyncV4Retained(objects, verifier, &highWaterFake{}, id, t.TempDir(), root); err != nil { + t.Fatal(err) + } + for _, ref := range verifier.full.Commitments.FinalReleaseArtifacts { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(ref.Name))); err != nil { + t.Fatalf("fresh sync omitted final release member %s: %v", ref.Name, err) + } + } +} + +func syncFixtureV4(t *testing.T, last int) (memoryObjects, *verifierV4Fake, string) { + t.Helper() + id := sum([]byte("ceremony")) + objects := memoryObjects{} + v := &verifierV4Fake{discoveries: map[string]transcript.CheckpointDiscoveryV4{}} + var previous *transcript.SignedArtifactRefs + artifact := func(name string, raw []byte) transcript.ArtifactRef { + objects[store.Key(sum(raw))] = raw + return transcript.ArtifactRef{Name: name, Digest: transcript.Digest{SHA256: sum(raw), Blake2b256: "blake2b256:" + sum(raw)[7:], Size: int64(len(raw))}} + } + definition := transcript.SignedArtifactRefs{ + Record: artifact("ceremony.json", []byte("signed definition")), + Signature: artifact("ceremony.sig", []byte("definition signature")), + } + chain := transcript.SignedArtifactRefs{ + Record: artifact("phase1/chain-0000.json", []byte("initial chain")), + Signature: artifact("phase1/chain-0000.sig", []byte("initial chain signature")), + } + genesis := artifact("phase1/genesis.bin", []byte("phase1 genesis")) + for n := 0; n <= last; n++ { + pair := transcript.SignedArtifactRefs{Record: artifact(fmt.Sprintf("checkpoints/%d.json", n), []byte(fmt.Sprintf("checkpoint-%d", n))), Signature: artifact(fmt.Sprintf("checkpoints/%d.sig", n), []byte(fmt.Sprintf("signature-%d", n)))} + d := transcript.CheckpointDiscoveryV4{Schema: "proof-tool-mpc-checkpoint-discovery-v4", Depth: "signed-checkpoint-discovery", CheckpointRefs: pair} + d.Discovery.CeremonyID, d.Discovery.Sequence, d.Discovery.PreviousCheckpoint, d.Discovery.VerificationDependencies = id, uint64(n), previous, []transcript.ArtifactRef{} + v.discoveries[pair.Record.Digest.SHA256] = d + v.full = transcript.CheckpointInspectionV4{Schema: "proof-tool-mpc-checkpoint-inspection-v4", Depth: "checkpoint-structure", CheckpointRefs: pair, Checkpoint: transcript.CheckpointStateV4{Schema: "proof-tool-mpc-checkpoint-v4", Workflow: "storage-first-v2", ReleaseVerification: "coordinator-full-replay-v1", CeremonyID: id, Sequence: uint64(n), Definition: definition, PreviousCheckpoint: previous, Progress: transcript.CheckpointProgressV4{Phase1: transcript.CheckpointPhaseState{Phase: "phase1", HeadRecordID: sum([]byte("phase1 head")), HeadPayload: genesis, Chain: chain}}, AcceptedArtifacts: []transcript.ArtifactRef{}, Deliveries: []transcript.DeliverySlotV4{}}} + v.full.Commitments = transcript.CheckpointCommitmentsV4{Enrollments: []transcript.SignedArtifactRefs{}, Turns: []transcript.TurnCommitmentV4{}} + copy := pair + previous = © + } + root := state.Root{Schema: state.RootSchema, CeremonyID: id, Checkpoint: contentRef(previous.Record), CheckpointSignature: contentRef(previous.Signature)} + raw, err := root.Encode() + if err != nil { + t.Fatal(err) + } + objects[state.RootKey(id)] = raw + return objects, v, id +} + +func TestSyncV4VerifiesOnceBeforeRecordingAndResumes(t *testing.T) { + objects, verifier, id := syncFixtureV4(t, 3) + h, err := state.OpenWorkspaceHighWater(t.TempDir(), id) + if err != nil { + t.Fatal(err) + } + verifier.reject = true + if _, err := SyncV4(objects, verifier, h, id, t.TempDir()); err == nil { + t.Fatal("illegal ancestry accepted") + } + if _, exists, err := h.Seen(); err != nil || exists { + t.Fatalf("failed verification changed progress: %v", err) + } + verifier.reject, verifier.fullCalls = false, 0 + snapshot, err := SyncV4(objects, verifier, h, id, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if verifier.fullCalls != 1 || snapshot.Checked() != 4 { + t.Fatal("unexpected verification count") + } + current, err := snapshot.State() + if err != nil { + t.Fatal(err) + } + current.Deliveries = append(current.Deliveries, transcript.DeliverySlotV4{AttemptID: "forged"}) + unchanged, _ := snapshot.State() + if len(unchanged.Deliveries) != 0 { + t.Fatal("snapshot mutation escaped") + } + if _, err := SyncV4(objects, verifier, h, id, t.TempDir()); err != nil { + t.Fatal(err) + } + if _, err := (SnapshotV4{}).State(); err == nil { + t.Fatal("empty snapshot usable") + } +} + +func TestSyncV4RejectsIncompleteOrInconsistentDiscovery(t *testing.T) { + for _, variant := range []string{"missing-signature", "missing-dependency", "conflicting-name", "oversized", "wrong-full-head", "wrong-full-policy", "wrong-full-ceremony", "wrong-full-signature", "wrong-predecessor-signature", "cycle", "sequence-limit", "wrong-sequence", "discovery-overclaim", "disk-full"} { + t.Run(variant, func(t *testing.T) { + objects, verifier, id := syncFixtureV4(t, 2) + h := &highWaterFake{} + last := verifier.full.CheckpointRefs + d := verifier.discoveries[last.Record.Digest.SHA256] + switch variant { + case "missing-signature": + delete(objects, store.Key(last.Signature.Digest.SHA256)) + case "missing-dependency": + d.Discovery.VerificationDependencies = []transcript.ArtifactRef{{Name: "missing", Digest: last.Record.Digest}} + d.Discovery.VerificationDependencies[0].Digest.SHA256 = sum([]byte("absent")) + case "conflicting-name": + d.Discovery.VerificationDependencies = []transcript.ArtifactRef{{Name: last.Record.Name, Digest: last.Signature.Digest}} + case "oversized": + d.Discovery.VerificationDependencies = []transcript.ArtifactRef{{Name: "too-big", Digest: last.Record.Digest}} + d.Discovery.VerificationDependencies[0].Digest.Size = 16<<20 + 1 + case "wrong-full-head": + verifier.full.Checkpoint.Sequence++ + case "wrong-full-policy": + verifier.full.Checkpoint.ReleaseVerification = "skip" + case "wrong-full-ceremony": + verifier.full.Checkpoint.CeremonyID = sum([]byte("other ceremony")) + case "wrong-full-signature": + verifier.full.CheckpointRefs.Signature.Digest.SHA256 = sum([]byte("other signature")) + case "wrong-predecessor-signature": + changed := *verifier.full.Checkpoint.PreviousCheckpoint + changed.Signature.Digest.SHA256 = sum([]byte("other signature")) + verifier.full.Checkpoint.PreviousCheckpoint = &changed + case "cycle": + d.Discovery.PreviousCheckpoint = &last + case "sequence-limit": + d.Discovery.Sequence = transcript.MaxCheckpointSequenceV4 + 1 + case "wrong-sequence": + d.Discovery.Sequence += 2 + case "discovery-overclaim": + d.AncestryVerified = true + case "disk-full": + h.fail = true + } + verifier.discoveries[last.Record.Digest.SHA256] = d + if _, err := SyncV4(objects, verifier, h, id, t.TempDir()); err == nil { + t.Fatal("bad discovery accepted") + } + if h.exists { + t.Fatal("failure advanced saved progress") + } + }) + } +} + +func TestSyncV4SupportsHistoryBeyondLegacyLimit(t *testing.T) { + for _, last := range []int{1025, transcript.MaxCheckpointSequenceV4} { + objects, verifier, id := syncFixtureV4(t, last) + s, err := SyncV4(objects, verifier, &highWaterFake{}, id, t.TempDir()) + if err != nil || s.Checked() != last+1 || verifier.fullCalls != 1 { + t.Fatalf("%d %v", s.Checked(), err) + } + } +} + +func TestSyncV4RejectsRollbackAndForkWithoutChangingSavedProgress(t *testing.T) { + objects, verifier, id := syncFixtureV4(t, 2) + h, err := state.OpenWorkspaceHighWater(t.TempDir(), id) + if err != nil { + t.Fatal(err) + } + if _, err := SyncV4(objects, verifier, h, id, t.TempDir()); err != nil { + t.Fatal(err) + } + before, _, _ := h.Seen() + oldObjects, oldVerifier, _ := syncFixtureV4(t, 1) + if _, err := SyncV4(oldObjects, oldVerifier, h, id, t.TempDir()); err == nil { + t.Fatal("rollback accepted") + } + // The correct sequence with a different signed history is not a resume. + forkHighWater := &highWaterFake{exists: true, position: state.CheckpointPosition{Sequence: 2, Digest: sum([]byte("other history"))}} + if _, err := SyncV4(objects, verifier, forkHighWater, id, t.TempDir()); err == nil { + t.Fatal("fork accepted") + } + after, _, _ := h.Seen() + if after.Sequence != before.Sequence || after.Digest != before.Digest { + t.Fatal("rollback changed progress") + } +} + +func TestSyncV4RequiresCurrentPayload(t *testing.T) { + objects, verifier, id := syncFixtureV4(t, 1) + body := []byte("current large payload fixture") + ref := transcript.ArtifactRef{Name: "phase1/current.bin", Digest: transcript.Digest{SHA256: sum(body), Blake2b256: "blake2b256:" + sum(body)[7:], Size: int64(len(body))}} + verifier.full.Checkpoint.Progress.Phase1.HeadPayload = ref + if _, err := SyncV4(objects, verifier, &highWaterFake{}, id, t.TempDir()); err == nil { + t.Fatal("missing current payload was accepted") + } + objects[store.Key(ref.Digest.SHA256)] = body + if _, err := SyncV4(objects, verifier, &highWaterFake{}, id, t.TempDir()); err != nil { + t.Fatalf("available current payload was rejected: %v", err) + } +} diff --git a/internal/storagefirst/turn_actions_v4.go b/internal/storagefirst/turn_actions_v4.go new file mode 100644 index 0000000..d0a4fe3 --- /dev/null +++ b/internal/storagefirst/turn_actions_v4.go @@ -0,0 +1,199 @@ +package storagefirst + +import ( + "errors" + "time" + + "github.com/zksecurity/relay/internal/transcript" +) + +type TurnGrantV4 struct { + AttemptID string + ExpiresAt time.Time +} + +// LocalTurnV4 is reconstructed from this role's exact retained artifacts and +// operation journal. It is not a signature verifier or a publication authority. +// Artifacts bind Scope; grants and uploaded manifests additionally bind attempts. +// Callers inspect an interrupted operation instead of presenting empty facts. +// Participant inventory identities must be reconstructed together by proof-tool: +// cleanup completes the fixed five-file candidate inventory. +// These strings alone do not establish that relationship. +type LocalTurnV4 struct { + Scope transcript.ContributionScopeV4 + PendingOperation bool + // GeneratedOutput is reconstructed by the three-file proof-tool inspection + // only after reconciling the original contributor container's absence. + // It does not establish cleanup confirmation or authorize uploading. + GeneratedOutput *transcript.ComputationOutputFactsV4 + CandidateInventory *transcript.ContributionInventoryFactsV4 + // CandidateAttemptID is the signed allocation under which the retained + // candidate was computed. It cannot move to a later allocation: proof-tool + // verifies that allocation precedes its contribution. + CandidateAttemptID string + ComputedCandidateID string + CandidateResultID string + CandidateReceivedAttemptID string + Grant *TurnGrantV4 + UploadedAttemptID string + UploadedArtifactID string +} + +type TurnRecommendationV4 struct { + Action string + Ready bool + Reason string + Scope transcript.ContributionScopeV4 + AttemptID string +} + +// RecommendTurnV4 does not execute work. Before any consequential action the +// caller re-syncs and verifies artifacts/grants; checkpoint proposals remain +// bound to their exact predecessor and conditional root version. +func (s SnapshotV4) RecommendTurnV4(protocol transcript.DefinitionProtocol, phase string, role Role, identity string, local LocalTurnV4, observedAttempt string, now time.Time) (TurnRecommendationV4, error) { + if role != Coordinator && role != Participant { + return TurnRecommendationV4{}, errors.New("unsupported turn role") + } + if role == Participant && identity == "" { + return TurnRecommendationV4{}, errors.New("participant identity required") + } + if role == Coordinator && identity != "" { + return TurnRecommendationV4{}, errors.New("coordinator turn selector must not override participant") + } + view, err := s.TurnV4(protocol, phase, identity) + if err != nil { + return TurnRecommendationV4{}, err + } + r := TurnRecommendationV4{Scope: view.Scope} + answer := func(action string, ready bool, reason string) (TurnRecommendationV4, error) { + r.Action, r.Ready, r.Reason = action, ready, reason + return r, nil + } + if local.PendingOperation { + r.Scope = local.Scope // Inspect the retained operation, even if the backend moved on. + return answer("inspect-retained-operation", true, "Inspect the interrupted operation before preparing new work.") + } + switch view.Stage { + case TurnTerminalV4: + return answer("ceremony-stopped", false, "The signed ceremony state stops further contributions.") + case TurnPhaseNotStartedV4: + return answer("wait-for-phase", false, "The coordinator has not initialized this phase.") + case TurnWaitingV4: + return answer("wait-for-your-turn", false, "An earlier scheduled participant must finish first.") + case TurnPhaseClosedV4, TurnPhaseCompleteV4: + return answer("phase-follow-up-not-connected", false, "This phase has no open participant turn; the V4 next-area workflow is not connected yet.") + } + if local.Scope != (transcript.ContributionScopeV4{}) && local.Scope != view.Scope { + return TurnRecommendationV4{}, errors.New("retained work belongs to another turn; select its exact scope before proceeding") + } + for _, digest := range []string{local.ComputedCandidateID, local.CandidateResultID, local.UploadedArtifactID} { + if digest != "" && !validDigest(digest) { + return TurnRecommendationV4{}, errors.New("invalid retained artifact identity") + } + } + if local.Scope == (transcript.ContributionScopeV4{}) && (local.GeneratedOutput != nil || local.CandidateInventory != nil || local.CandidateAttemptID != "" || local.ComputedCandidateID != "" || local.CandidateResultID != "" || local.Grant != nil || local.UploadedAttemptID != "") { + return TurnRecommendationV4{}, errors.New("retained work has no exact turn scope") + } + if local.GeneratedOutput != nil && local.GeneratedOutput.Scope != view.Scope { + return TurnRecommendationV4{}, errors.New("generated output belongs to another turn") + } + if local.CandidateInventory != nil && (local.CandidateInventory.Scope != view.Scope || local.CandidateInventory.ComputedCandidateID != local.ComputedCandidateID || local.CandidateInventory.CandidateResultID != local.CandidateResultID || (role == Participant && !validAttempt(local.CandidateAttemptID))) { + return TurnRecommendationV4{}, errors.New("completed candidate inventory differs from retained turn facts") + } + if local.UploadedAttemptID != "" && !validAttempt(local.UploadedAttemptID) { + return TurnRecommendationV4{}, errors.New("invalid retained upload attempt") + } + if local.CandidateReceivedAttemptID != "" && (!validAttempt(local.CandidateReceivedAttemptID) || local.CandidateResultID == "") { + return answer("inspect-retained-operation", true, "A received-candidate record is incomplete. Inspect it before continuing.") + } + if local.Grant != nil && (!validAttempt(local.Grant.AttemptID) || local.Grant.ExpiresAt.IsZero()) { + return TurnRecommendationV4{}, errors.New("invalid retained grant") + } + if observedAttempt != "" && !validAttempt(observedAttempt) { + return TurnRecommendationV4{}, errors.New("invalid observed submission attempt") + } + if (local.UploadedAttemptID == "") != (local.UploadedArtifactID == "") { + return TurnRecommendationV4{}, errors.New("inconsistent retained artifact bindings") + } + if role == Participant && local.CandidateResultID != "" && local.ComputedCandidateID == "" { + return answer("inspect-retained-operation", true, "The completed upload package has no verified computation inventory. Inspect its exact files before continuing.") + } + c, err := s.State() + if err != nil { + return TurnRecommendationV4{}, err + } + if local.UploadedAttemptID != "" { + matched := false + for _, delivery := range c.Deliveries { + if delivery.Scope != view.Scope || delivery.AttemptID != local.UploadedAttemptID { + continue + } + matched = delivery.Kind == "candidate" && local.CandidateResultID != "" && local.UploadedArtifactID == local.CandidateResultID + } + if !matched { + return answer("inspect-retained-operation", true, "A recorded upload has no matching retained artifact and exact delivery attempt. Inspect existing work before signing or computing again.") + } + } + if role == Participant && local.GeneratedOutput != nil && local.ComputedCandidateID == "" && (view.Stage == TurnCandidateV4 || view.Stage == TurnReallocateV4) { + return answer("confirm-cleanup-and-sign-attestation", true, "Computation output is verified. Check container cleanup and confirm your precautions before signing the cleanup statement; do not contribute again.") + } + switch view.Stage { + case TurnEnrollmentV4: + if role == Coordinator { + return answer("collect-participant-enrollment", true, "Receive and verify this scheduled participant's signed enrollment.") + } + return answer("submit-your-enrollment", true, "Send your signed enrollment through the ceremony submission service.") + case TurnAllocationV4: + if role == Coordinator { + return answer("allocate-candidate-attempt", true, "Allocate one candidate attempt for this exact participant, phase, turn and current head.") + } + return answer("wait-for-candidate-allocation", false, "The coordinator must publish your signed candidate allocation.") + case TurnAcceptedV4: + if local.CandidateResultID == "" { + return answer("inspect-accepted-result", true, "The coordinator accepted this turn; recover and verify the exact result before marking local work complete.") + } + if local.CandidateResultID != view.Commitment.AcceptedChain.ContributionResultID { + return answer("inspect-different-accepted-result", true, "The accepted result differs from your retained candidate. Do not upload it again.") + } + return answer("turn-complete", false, "The coordinator's accepted result matches your exact retained contribution.") + case TurnReallocateV4: + if role == Coordinator { + return answer("allocate-replacement-attempt", true, "No active upload attempt remains; review retained work before allocating a replacement.") + } + return answer("wait-for-replacement-attempt", false, "Keep the rejected candidate as history. The coordinator must allocate a replacement before you make one fresh contribution.") + } + slot := view.CandidateAttempt + if slot == nil { + return TurnRecommendationV4{}, errors.New("turn has no supported active attempt") + } + r.AttemptID = slot.AttemptID + grantReady := local.Grant != nil && local.Grant.AttemptID == slot.AttemptID && !now.IsZero() && local.Grant.ExpiresAt.After(now) + if role == Coordinator { + if observedAttempt == slot.AttemptID { + if local.CandidateResultID == "" || local.CandidateReceivedAttemptID != slot.AttemptID { + return answer("download-and-check-candidate", true, "Download the exact five-file candidate and verify its signed inventory.") + } + return answer("verify-and-accept-candidate", true, "Replay and verify this exact candidate, then conditionally publish its signed acceptance checkpoint.") + } + if !grantReady { + return answer("issue-candidate-grant", true, "Issue or renew upload access for this exact active candidate attempt.") + } + return answer("wait-for-candidate", false, "Waiting for the candidate manifest; upload alone will not mean acceptance.") + } + if local.ComputedCandidateID == "" { + return answer("contribute", true, "The signed allocation authorizes this exact turn. Proof-tool will recheck it and the complete input snapshot before generating randomness.") + } + if role == Participant && local.CandidateAttemptID != slot.AttemptID { + return answer("contribute", true, "Your retained candidate belongs to a retired allocation. Preserve it for investigation; this replacement allocation requires a fresh contribution and cleanup statement.") + } + if local.UploadedAttemptID == slot.AttemptID && local.UploadedArtifactID == local.CandidateResultID { + return answer("wait-for-candidate-acceptance", false, "Candidate manifest uploaded; wait for the coordinator's exact signed result.") + } + if local.CandidateResultID == "" { + return answer("confirm-cleanup-and-sign-attestation", true, "Verify cleanup and complete the fixed five-file candidate; do not recompute.") + } + if !grantReady { + return answer("get-candidate-grant", false, "Keep the completed candidate and return packet; obtain current upload access.") + } + return answer("upload-candidate", true, "Upload the retained five-file candidate, then publish its manifest last without recomputing.") +} diff --git a/internal/storagefirst/turn_actions_v4_test.go b/internal/storagefirst/turn_actions_v4_test.go new file mode 100644 index 0000000..f48b95c --- /dev/null +++ b/internal/storagefirst/turn_actions_v4_test.go @@ -0,0 +1,119 @@ +package storagefirst + +import ( + "strings" + "testing" + "time" + + "github.com/zksecurity/relay/internal/transcript" +) + +func enrolledTurnV4(t *testing.T, phase string) (SnapshotV4, transcript.DefinitionProtocol, transcript.CheckpointStateV4, transcript.CheckpointCommitmentsV4, transcript.EnrollmentMetadataV4, TurnViewV4) { + t.Helper() + _, p, c, index, enrollments := turnFixtureV4(t, phase) + schedule, _ := p.Definition.Schedule(phase) + for _, required := range p.Definition.Journey.RequiredEnrollments { + if required.Identity.ID == schedule[0] { + enrollments.Enrollments = append(enrollments.Enrollments, transcript.CommittedEnrollmentMetadataV4{Enrollment: transcript.EnrollmentInspection{Role: required.Role, RoleIndex: required.RoleIndex, Identity: required.Identity}}) + } + } + s := encodeTurnFixtureV4(t, c, index, enrollments) + view, err := s.TurnV4(p, phase, schedule[0]) + if err != nil || view.Stage != TurnAllocationV4 { + t.Fatal(view, err) + } + return s, p, c, index, enrollments, view +} + +func TestV4ParticipantTurnRecommendationsAndRetries(t *testing.T) { + for _, phase := range []string{"phase1", "phase2"} { + t.Run(phase, func(t *testing.T) { + _, p, c, index, enrollments, view := enrolledTurnV4(t, phase) + who := view.Scope.ParticipantID + now := time.Date(2026, 9, 16, 0, 0, 0, 0, time.UTC) + attempt := strings.Repeat("ab", 16) + index.Turns = []transcript.TurnCommitmentV4{{Scope: view.Scope, Allocations: []transcript.CandidateAllocationV4{{CheckpointSequence: 1, Checkpoint: allocationPairV4(1), AttemptID: attempt, AllocatedAt: now.Format(time.RFC3339)}}}} + c.Deliveries = []transcript.DeliverySlotV4{{Scope: view.Scope, Kind: "candidate", AttemptID: attempt, Status: "allocated"}} + local := LocalTurnV4{Scope: view.Scope} + check := func(action string, ready bool) TurnRecommendationV4 { + t.Helper() + s := encodeTurnFixtureV4(t, c, index, enrollments) + r, err := s.RecommendTurnV4(p, phase, Participant, who, local, "", now) + if err != nil || r.Action != action || r.Ready != ready { + t.Fatalf("got %+v %v want %s %v", r, err, action, ready) + } + return r + } + check("contribute", true) + local.PendingOperation = true + check("inspect-retained-operation", true) + local.PendingOperation = false + local.GeneratedOutput = &transcript.ComputationOutputFactsV4{Scope: view.Scope} + check("confirm-cleanup-and-sign-attestation", true) + local.ComputedCandidateID = sum([]byte("computed-five-files")) + local.CandidateResultID = sum([]byte("result-five-files")) + local.CandidateAttemptID = attempt + check("get-candidate-grant", false) + local.Grant = &TurnGrantV4{AttemptID: attempt, ExpiresAt: now.Add(time.Hour)} + check("upload-candidate", true) + local.UploadedAttemptID = attempt + local.UploadedArtifactID = local.CandidateResultID + c.Deliveries[0].ContributionResultID = local.CandidateResultID + check("wait-for-candidate-acceptance", false) + c.Deliveries[0].Status = "rejected" + c.Deliveries[0].ContributionResultID = local.CandidateResultID + check("wait-for-replacement-attempt", false) + replacement := strings.Repeat("cd", 16) + c.Deliveries = append(c.Deliveries, transcript.DeliverySlotV4{Scope: view.Scope, Kind: "candidate", AttemptID: replacement, Status: "allocated"}) + index.Turns[0].Allocations = append([]transcript.CandidateAllocationV4{{CheckpointSequence: 2, Checkpoint: allocationPairV4(2), AttemptID: replacement, AllocatedAt: now.Add(time.Minute).Format(time.RFC3339)}}, index.Turns[0].Allocations...) + local.Grant = &TurnGrantV4{AttemptID: replacement, ExpiresAt: now.Add(time.Hour)} + check("contribute", true) + // A fresh computation under the replacement allocation can now be + // uploaded and accepted; the rejected candidate is never reused. + local.CandidateAttemptID = replacement + local.ComputedCandidateID = sum([]byte("replacement-computed-five-files")) + local.CandidateResultID = sum([]byte("replacement-result-five-files")) + local.UploadedAttemptID = "" + local.UploadedArtifactID = "" + check("upload-candidate", true) + c.Deliveries[1].Status = "accepted" + c.Deliveries[1].ContributionResultID = local.CandidateResultID + index.Turns[0].AcceptedChain = &transcript.AcceptedChainCommitmentV4{AttemptID: replacement, ContributionResultID: local.CandidateResultID} + if phase == "phase1" { + c.Progress.Phase1.AcceptedCount = 1 + } else { + c.Progress.Phase2.AcceptedCount = 1 + } + check("turn-complete", false) + }) + } +} + +func TestV4CoordinatorVerifiesSubmissionBeforeAnotherAction(t *testing.T) { + _, p, c, index, enrollments, view := enrolledTurnV4(t, "phase1") + now := time.Now().UTC() + attempt := strings.Repeat("ab", 16) + index.Turns = []transcript.TurnCommitmentV4{{Scope: view.Scope, Allocations: []transcript.CandidateAllocationV4{{CheckpointSequence: 1, Checkpoint: allocationPairV4(1), AttemptID: attempt, AllocatedAt: now.Format(time.RFC3339)}}}} + c.Deliveries = []transcript.DeliverySlotV4{{Scope: view.Scope, Kind: "candidate", AttemptID: attempt, Status: "allocated"}} + local := LocalTurnV4{Scope: view.Scope} + s := encodeTurnFixtureV4(t, c, index, enrollments) + r, err := s.RecommendTurnV4(p, "phase1", Coordinator, "", local, attempt, now) + if err != nil || r.Action != "download-and-check-candidate" { + t.Fatal(r, err) + } + local.CandidateResultID = sum([]byte("incoming")) + local.CandidateReceivedAttemptID = attempt + r, err = s.RecommendTurnV4(p, "phase1", Coordinator, "", local, attempt, now) + if err != nil || r.Action != "verify-and-accept-candidate" { + t.Fatal(r, err) + } +} + +func TestV4PendingOperationKeepsItsOriginalScope(t *testing.T) { + s, p, _, _, _ := turnFixtureV4(t, "phase1") + old := transcript.ContributionScopeV4{CeremonyID: p.Definition.CeremonyID, Phase: "phase1", Index: 1, ParticipantID: "p1", ParentHeadID: sum([]byte("old"))} + r, err := s.RecommendTurnV4(p, "phase1", Participant, "p2", LocalTurnV4{Scope: old, PendingOperation: true}, "", time.Now()) + if err != nil || r.Action != "inspect-retained-operation" || r.Scope != old { + t.Fatal("interrupted operation scope was replaced", r, err) + } +} diff --git a/internal/storagefirst/turn_v4.go b/internal/storagefirst/turn_v4.go new file mode 100644 index 0000000..7c8d1c7 --- /dev/null +++ b/internal/storagefirst/turn_v4.go @@ -0,0 +1,179 @@ +package storagefirst + +import ( + "errors" + "fmt" + + "github.com/zksecurity/relay/internal/transcript" +) + +// TurnViewV4 is a backend-derived view, not permission to compute or publish. +// Local artifacts, current grants and mutation recovery are checked separately. +type TurnViewV4 struct { + Stage string + Scope transcript.ContributionScopeV4 + Commitment *transcript.TurnCommitmentV4 + ReceiptAttempt *transcript.DeliverySlotV4 // receipt-era development state; never populated by released V4 + CandidateAttempt *transcript.DeliverySlotV4 +} + +const ( + TurnPhaseNotStartedV4 = "phase-not-started" + TurnWaitingV4 = "waiting-for-earlier-participant" + TurnEnrollmentV4 = "participant-enrollment-needed" + TurnAllocationV4 = "candidate-allocation-needed" + TurnReceiptV4 = "receipt-era-state-not-supported" + TurnCandidateV4 = "candidate-needed" + TurnReallocateV4 = "replacement-attempt-needed" + TurnAcceptedV4 = "accepted-result" + TurnPhaseClosedV4 = "phase-closed" + TurnPhaseCompleteV4 = "scheduled-turns-complete" + TurnTerminalV4 = "ceremony-stopped" +) + +// TurnV4 derives a turn from authenticated progress and commitments rather than +// the latest checkpoint event. An empty participant selects the coordinator's +// current turn. A named participant always sees its own position, even when +// another participant now owns the current turn. No legacy fallback occurs. +func (s SnapshotV4) TurnV4(protocol transcript.DefinitionProtocol, phase, participant string) (TurnViewV4, error) { + c, err := s.State() + if err != nil { + return TurnViewV4{}, err + } + if !protocol.UsesV4() || protocol.Definition.CeremonyID != c.CeremonyID { + return TurnViewV4{}, errors.New("turn guidance requires the matching authenticated V4 definition") + } + index, err := s.Commitments() + if err != nil { + return TurnViewV4{}, err + } + enrollments, err := s.Enrollments() + if err != nil { + return TurnViewV4{}, err + } + d := protocol.Definition + schedule, err := d.Schedule(phase) + if err != nil { + return TurnViewV4{}, err + } + if len(schedule) == 0 || len(schedule) > 20 { + return TurnViewV4{}, errors.New("invalid authenticated turn schedule") + } + seen := map[string]bool{} + for _, id := range schedule { + if id == "" || seen[id] { + return TurnViewV4{}, errors.New("ambiguous authenticated turn schedule") + } + seen[id] = true + } + position := 0 + if participant != "" { + position, err = d.SlotOf(phase, participant) + if err != nil { + return TurnViewV4{}, err + } + } + if c.Progress.Terminal != nil { + return TurnViewV4{Stage: TurnTerminalV4}, nil + } + var progress *transcript.CheckpointPhaseState + var closure *transcript.SignedArtifactRefs + if phase == "phase1" { + progress = &c.Progress.Phase1 + closure = c.Progress.Phase1Closure + } else { + progress = c.Progress.Phase2 + closure = c.Progress.Phase2Closure + } + if progress == nil { + return TurnViewV4{Stage: TurnPhaseNotStartedV4}, nil + } + accepted := int(progress.AcceptedCount) + if accepted > len(schedule) { + return TurnViewV4{}, errors.New("accepted count exceeds authenticated schedule") + } + if position == 0 { + position = accepted + 1 + } + if position > len(schedule) { + return TurnViewV4{Stage: TurnPhaseCompleteV4}, nil + } + view := TurnViewV4{Scope: transcript.ContributionScopeV4{CeremonyID: c.CeremonyID, Phase: phase, Index: uint8(position), ParticipantID: schedule[position-1]}} + for _, turn := range index.Turns { + if turn.Scope.Phase == phase && int(turn.Scope.Index) == position { + if view.Commitment != nil || turn.Scope.CeremonyID != c.CeremonyID || turn.Scope.ParticipantID != view.Scope.ParticipantID { + return TurnViewV4{}, errors.New("turn commitment differs from authenticated schedule") + } + copy := turn + view.Commitment = © + view.Scope = turn.Scope + } + } + if position <= accepted { + if view.Commitment == nil || view.Commitment.AcceptedChain == nil { + return TurnViewV4{}, errors.New("accepted turn has no exact chain/result commitment") + } + view.Stage = TurnAcceptedV4 + return view, nil + } + if closure != nil { + view.Stage = TurnPhaseClosedV4 + return view, nil + } + if position > accepted+1 { + view.Stage = TurnWaitingV4 + return view, nil + } + if view.Commitment != nil && view.Scope.ParentHeadID != progress.HeadRecordID { + return TurnViewV4{}, errors.New("current turn has a different predecessor") + } + view.Scope.ParentHeadID = progress.HeadRecordID + for _, slot := range c.Deliveries { + if slot.Scope != view.Scope || slot.Status != "allocated" { + continue + } + copy := slot + if slot.Kind != "candidate" { + return TurnViewV4{}, fmt.Errorf("unknown active delivery kind %q", slot.Kind) + } + if view.CandidateAttempt != nil { + return TurnViewV4{}, errors.New("multiple active candidate attempts") + } + view.CandidateAttempt = © + } + // Outbound authoring requires this participant's enrollment, not completion + // of the entire final-evidence roster. Other enrollments remain parallel work. + journey, err := d.RequireJourney() + if err != nil { + return TurnViewV4{}, err + } + var expected *transcript.ExpectedEnrollment + for _, e := range journey.RequiredEnrollments { + if e.Role == "participant" && e.Identity.ID == view.Scope.ParticipantID { + copy := e + expected = © + } + } + if expected == nil { + return TurnViewV4{}, errors.New("scheduled participant missing from required enrollment projection") + } + view.Stage = TurnEnrollmentV4 + for _, item := range enrollments.Enrollments { + e := item.Enrollment + if e.Role == expected.Role && e.RoleIndex == expected.RoleIndex && e.Identity == expected.Identity { + view.Stage = TurnAllocationV4 + break + } + } + if view.Stage == TurnEnrollmentV4 { + return view, nil + } + if view.CandidateAttempt != nil { + view.Stage = TurnCandidateV4 + return view, nil + } + if view.Commitment != nil && len(view.Commitment.Allocations) > 0 { + view.Stage = TurnReallocateV4 + } + return view, nil +} diff --git a/internal/storagefirst/turn_v4_test.go b/internal/storagefirst/turn_v4_test.go new file mode 100644 index 0000000..3583491 --- /dev/null +++ b/internal/storagefirst/turn_v4_test.go @@ -0,0 +1,146 @@ +package storagefirst + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/zksecurity/relay/internal/transcript" +) + +func allocationPairV4(sequence uint64) transcript.SignedArtifactRefs { + ref := func(name string) transcript.ArtifactRef { + return transcript.ArtifactRef{Name: name, Digest: transcript.Digest{SHA256: sum([]byte(name)), Blake2b256: "blake2b256:" + strings.Repeat("b", 64), Size: 1}} + } + prefix := fmt.Sprintf("checkpoints/%04d", sequence) + return transcript.SignedArtifactRefs{Record: ref(prefix + ".json"), Signature: ref(prefix + ".sig")} +} + +// Fixtures are already-verified projection data, not a cryptographic test. +func turnFixtureV4(t *testing.T, phase string) (SnapshotV4, transcript.DefinitionProtocol, transcript.CheckpointStateV4, transcript.CheckpointCommitmentsV4, transcript.EnrollmentMetadataV4) { + t.Helper() + id := sum([]byte("ceremony")) + head := sum([]byte("head")) + d := transcript.Definition{CeremonyID: id, Phase1Participants: []string{"p1", "p2"}, Phase2Participants: []string{"p2", "p1"}, Journey: &transcript.DefinitionJourney{Schema: "proof-tool-mpc-definition-journey-v2", ObserverRequirementSource: "signed policy"}} + for _, entry := range []struct { + role, id string + index int + }{{"coordinator", "coord", 1}, {"release-signer", "signer", 1}, {"participant", "p1", 1}, {"participant", "p2", 2}} { + d.Journey.RequiredEnrollments = append(d.Journey.RequiredEnrollments, transcript.ExpectedEnrollment{Role: entry.role, RoleIndex: entry.index, Identity: transcript.PublicIdentity{ID: entry.id, KeyID: "key-" + entry.id, PublicKeyFingerprint: "fingerprint-" + entry.id}}) + } + p := transcript.DefinitionProtocol{DefinitionSchema: "proof-tool-mpc-ceremony-definition-v4", StorageWorkflow: "storage-first-v2", ReleaseVerification: "coordinator-full-replay-v1", Definition: d} + c := transcript.CheckpointStateV4{CeremonyID: id, Deliveries: []transcript.DeliverySlotV4{}, Progress: transcript.CheckpointProgressV4{Phase1: transcript.CheckpointPhaseState{Phase: "phase1", HeadRecordID: head}}} + if phase == "phase2" { + c.Progress.Phase2 = &transcript.CheckpointPhaseState{Phase: "phase2", HeadRecordID: head} + } + index := transcript.CheckpointCommitmentsV4{Enrollments: []transcript.SignedArtifactRefs{}, Turns: []transcript.TurnCommitmentV4{}} + e := transcript.EnrollmentMetadataV4{CeremonyID: id, Enrollments: []transcript.CommittedEnrollmentMetadataV4{}} + return encodeTurnFixtureV4(t, c, index, e), p, c, index, e +} + +func encodeTurnFixtureV4(t *testing.T, c transcript.CheckpointStateV4, index transcript.CheckpointCommitmentsV4, e transcript.EnrollmentMetadataV4) SnapshotV4 { + t.Helper() + encode := func(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b + } + return SnapshotV4{inspection: encode(c), commitments: encode(index), enrollments: encode(e)} +} + +func TestTurnV4BothPhasesUseProgressNotLatestEvent(t *testing.T) { + for _, phase := range []string{"phase1", "phase2"} { + t.Run(phase, func(t *testing.T) { + _, p, c, index, e := turnFixtureV4(t, phase) + schedule, _ := p.Definition.Schedule(phase) + who := schedule[0] + check := func(want string) { + t.Helper() + s := encodeTurnFixtureV4(t, c, index, e) + for _, actor := range []string{"", who} { + v, err := s.TurnV4(p, phase, actor) + if err != nil || v.Stage != want { + t.Fatalf("actor %q got %+v %v want %s", actor, v, err, want) + } + } + } + check(TurnEnrollmentV4) + for _, required := range p.Definition.Journey.RequiredEnrollments { + if required.Identity.ID == who { + e.Enrollments = append(e.Enrollments, transcript.CommittedEnrollmentMetadataV4{Enrollment: transcript.EnrollmentInspection{Role: required.Role, RoleIndex: required.RoleIndex, Identity: required.Identity}}) + } + } + check(TurnAllocationV4) // Other required enrollments deliberately absent. + view, err := encodeTurnFixtureV4(t, c, index, e).TurnV4(p, phase, who) + if err != nil { + t.Fatal(err) + } + candidateAttempt := strings.Repeat("cd", 16) + turn := transcript.TurnCommitmentV4{Scope: view.Scope, Allocations: []transcript.CandidateAllocationV4{{CheckpointSequence: 1, Checkpoint: allocationPairV4(1), AttemptID: candidateAttempt, AllocatedAt: "2026-09-16T00:00:00Z"}}} + index.Turns = []transcript.TurnCommitmentV4{turn} + c.Deliveries = []transcript.DeliverySlotV4{{Scope: view.Scope, Kind: "candidate", AttemptID: candidateAttempt, Status: "allocated"}} + check(TurnCandidateV4) + c.Sequence += 4 // Unrelated committed evidence does not change this turn. + check(TurnCandidateV4) + c.Deliveries[0].Status = "retired" + check(TurnReallocateV4) + replacement := strings.Repeat("ef", 16) + c.Deliveries = append(c.Deliveries, transcript.DeliverySlotV4{Scope: view.Scope, Kind: "candidate", AttemptID: replacement, Status: "allocated"}) + index.Turns[0].Allocations = append([]transcript.CandidateAllocationV4{{CheckpointSequence: c.Sequence, Checkpoint: allocationPairV4(c.Sequence), AttemptID: replacement, AllocatedAt: "2026-09-16T00:01:00Z"}}, index.Turns[0].Allocations...) + check(TurnCandidateV4) + result := sum([]byte("result")) + c.Deliveries[1].Status = "accepted" + c.Deliveries[1].ContributionResultID = result + index.Turns[0].AcceptedChain = &transcript.AcceptedChainCommitmentV4{AttemptID: replacement, ContributionResultID: result} + if phase == "phase1" { + c.Progress.Phase1.AcceptedCount = 1 + c.Progress.Phase1.HeadRecordID = sum([]byte("next")) + } else { + c.Progress.Phase2.AcceptedCount = 1 + c.Progress.Phase2.HeadRecordID = sum([]byte("next")) + } + v, err := encodeTurnFixtureV4(t, c, index, e).TurnV4(p, phase, who) + if err != nil || v.Stage != TurnAcceptedV4 || v.Scope != view.Scope { + t.Fatal("earlier participant lost accepted result", v, err) + } + v, err = encodeTurnFixtureV4(t, c, index, e).TurnV4(p, phase, "") + if err != nil || v.Stage != TurnEnrollmentV4 || v.Scope.ParticipantID != schedule[1] { + t.Fatal("coordinator did not move to next participant", v, err) + } + }) + } +} + +func TestTurnV4NoLegacyFallbackOrPrematureTurn(t *testing.T) { + s, p, c, index, e := turnFixtureV4(t, "phase1") + if _, err := (SnapshotV4{}).TurnV4(p, "phase1", ""); err == nil { + t.Fatal("empty snapshot usable") + } + if v, err := s.TurnV4(p, "phase2", "p1"); err != nil || v.Stage != TurnPhaseNotStartedV4 { + t.Fatal(v, err) + } + if v, err := s.TurnV4(p, "phase1", "p2"); err != nil || v.Stage != TurnWaitingV4 { + t.Fatal(v, err) + } + if _, err := s.TurnV4(p, "phase1", "stranger"); err == nil { + t.Fatal("unassigned participant accepted") + } + bad := p + bad.DefinitionSchema = "proof-tool-mpc-ceremony-definition-v3" + if _, err := s.TurnV4(bad, "phase1", ""); err == nil { + t.Fatal("legacy fallback") + } + c.Progress.Phase1Closure = &transcript.SignedArtifactRefs{} + if v, err := encodeTurnFixtureV4(t, c, index, e).TurnV4(p, "phase1", ""); err != nil || v.Stage != TurnPhaseClosedV4 { + t.Fatal(v, err) + } + if err := json.Unmarshal([]byte(`{"kind":"abort","record":{}}`), &c.Progress.Terminal); err != nil { + t.Fatal(err) + } + if v, err := encodeTurnFixtureV4(t, c, index, e).TurnV4(p, "phase1", "p1"); err != nil || v.Stage != TurnTerminalV4 { + t.Fatal(v, err) + } +} diff --git a/internal/store/conditional.go b/internal/store/conditional.go new file mode 100644 index 0000000..4aba9aa --- /dev/null +++ b/internal/store/conditional.go @@ -0,0 +1,283 @@ +package store + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +// ErrVersionConflict reports that a conditional replacement did not match the +// exact object version previously read. Callers must resynchronize; retrying +// with a different version would be a different state transition. +var ErrVersionConflict = errors.New("object version conflict") + +// publicReadTimeout bounds a single read from the unauthenticated public +// distribution endpoint. The synchronizer can safely retry a failed read, but +// must never let one stalled CDN connection hold a role workflow forever. +var publicReadTimeout = 30 * time.Second + +// ObjectVersion is the storage-provider version returned with an object read +// or write. ETag is the conditional-write token used by S3 and R2. VersionID is +// retained when the provider supplies one so recovery records can identify the +// exact read more precisely. +type ObjectVersion struct { + ETag string `json:"etag"` + VersionID string `json:"version_id,omitempty"` + Size int64 `json:"size"` +} + +func validateVersion(version ObjectVersion) error { + if version.ETag == "" || len(version.ETag) > 1024 || strings.ContainsAny(version.ETag, "\x00\r\n") { + return errors.New("object version has an invalid ETag") + } + if len(version.VersionID) > 1024 || strings.ContainsAny(version.VersionID, "\x00\r\n") { + return errors.New("object version has an invalid version ID") + } + if version.Size < 0 { + return errors.New("object version has a negative size") + } + return nil +} + +func validateKey(key string) error { + if key == "" || len(key) > 1024 || path.Clean(key) != key || strings.HasPrefix(key, "/") || + key == ".." || strings.HasPrefix(key, "../") || strings.ContainsAny(key, "\\\x00\r\n") { + return fmt.Errorf("unsafe object key %q", key) + } + return nil +} + +// HeadVersion returns the current authenticated provider metadata. It is not a +// substitute for downloading and verifying the object bytes. +func (c Client) HeadVersion(key string) (ObjectVersion, error) { + if c.PublicBaseURL != "" { + return ObjectVersion{}, errors.New("versioned provider metadata requires authenticated object storage") + } + if err := validateKey(key); err != nil { + return ObjectVersion{}, err + } + raw, err := c.run("head-object", "--bucket", c.Bucket, "--key", key, "--output", "json") + if err != nil { + return ObjectVersion{}, err + } + var response struct { + ETag string `json:"ETag"` + VersionID string `json:"VersionId"` + ContentLength *int64 `json:"ContentLength"` + } + if err := json.Unmarshal(raw, &response); err != nil || response.ContentLength == nil { + return ObjectVersion{}, errors.New("object storage returned invalid version metadata") + } + version := ObjectVersion{ETag: response.ETag, VersionID: response.VersionID, Size: *response.ContentLength} + if err := validateVersion(version); err != nil { + return ObjectVersion{}, err + } + return version, nil +} + +// GetVersionedAtMost downloads a small object without allowing it to grow past +// maximum. Authenticated reads pin the HEAD result with If-Match (and the +// provider version ID when present), so a replacement between HEAD and GET is +// rejected rather than mixed into the caller's state transition. Public reads +// are bounded in the HTTP stream and return any ETag supplied by the origin. +func (c Client) GetVersionedAtMost(key, local string, maximum int64) (ObjectVersion, error) { + if maximum < 0 || maximum > 16<<30 { + return ObjectVersion{}, errors.New("invalid bounded download limit") + } + if c.PublicBaseURL != "" { + return c.getPublicVersionedAtMost(key, local, maximum) + } + if _, err := os.Lstat(local); !errors.Is(err, os.ErrNotExist) { + return ObjectVersion{}, errors.New("download destination must be fresh") + } + version, err := c.HeadVersion(key) + if err != nil { + return ObjectVersion{}, err + } + if version.Size > maximum { + return ObjectVersion{}, errors.New("object exceeds download limit") + } + if err := os.MkdirAll(filepath.Dir(local), 0o700); err != nil { + return ObjectVersion{}, err + } + args := []string{"get-object", "--bucket", c.Bucket, "--key", key, "--if-match", version.ETag} + if version.VersionID != "" && version.VersionID != "null" { + args = append(args, "--version-id", version.VersionID) + } + args = append(args, "--output", "json", local) + raw, err := c.run(args...) + if err != nil { + _ = os.Remove(local) + if conditionalFailure(err) { + return ObjectVersion{}, fmt.Errorf("%w: object changed during bounded read", ErrVersionConflict) + } + return ObjectVersion{}, err + } + var downloaded struct { + ETag string `json:"ETag"` + VersionID string `json:"VersionId"` + } + if len(bytes.TrimSpace(raw)) != 0 { + if err := json.Unmarshal(raw, &downloaded); err != nil { + _ = os.Remove(local) + return ObjectVersion{}, errors.New("object storage returned invalid download metadata") + } + if downloaded.ETag != "" && downloaded.ETag != version.ETag { + _ = os.Remove(local) + return ObjectVersion{}, fmt.Errorf("%w: downloaded ETag differs from the pinned read", ErrVersionConflict) + } + if version.VersionID != "" && version.VersionID != "null" && downloaded.VersionID != "" && downloaded.VersionID != version.VersionID { + _ = os.Remove(local) + return ObjectVersion{}, fmt.Errorf("%w: downloaded version differs from the pinned read", ErrVersionConflict) + } + } + info, err := os.Lstat(local) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() != version.Size { + _ = os.Remove(local) + return ObjectVersion{}, errors.New("downloaded object size or file type differs from version metadata") + } + return version, nil +} + +func (c Client) getPublicVersionedAtMost(key, local string, maximum int64) (ObjectVersion, error) { + if err := validateKey(key); err != nil { + return ObjectVersion{}, err + } + if _, err := os.Lstat(local); !errors.Is(err, os.ErrNotExist) { + return ObjectVersion{}, errors.New("download destination must be fresh") + } + base, err := url.Parse(c.PublicBaseURL) + if err != nil || base.Scheme != "https" || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" { + return ObjectVersion{}, errors.New("invalid public base URL") + } + base.Path = strings.TrimSuffix(base.Path, "/") + "/" + key + origin := base.Scheme + "://" + base.Host + client := http.DefaultClient + if c.httpClient != nil { + client = c.httpClient + } + boundedClient := *client + boundedClient.Timeout = publicReadTimeout + boundedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 || req.URL.Scheme+"://"+req.URL.Host != origin { + return errors.New("public object redirect left the configured origin") + } + return nil + } + // Client.Timeout is a useful backstop, but make the deadline explicit on + // the request as well. That ensures custom transports and blocked response + // bodies receive cancellation rather than leaving a synchronizer stranded. + ctx, cancel := context.WithTimeout(context.Background(), publicReadTimeout) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, base.String(), nil) // #nosec G107 -- validated operator-configured HTTPS origin. + if err != nil { + return ObjectVersion{}, err + } + response, err := boundedClient.Do(request) + if err != nil { + return ObjectVersion{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + return ObjectVersion{}, fmt.Errorf("GET %s: HTTP %s", key, response.Status) + } + if response.ContentLength > maximum { + return ObjectVersion{}, errors.New("object exceeds download limit") + } + if err := os.MkdirAll(filepath.Dir(local), 0o700); err != nil { + return ObjectVersion{}, err + } + file, err := os.OpenFile(local, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return ObjectVersion{}, err + } + written, copyErr := io.Copy(file, io.LimitReader(response.Body, maximum+1)) + closeErr := file.Close() + if copyErr != nil || closeErr != nil || written > maximum || (response.ContentLength >= 0 && written != response.ContentLength) { + _ = os.Remove(local) + switch { + case copyErr != nil: + return ObjectVersion{}, copyErr + case closeErr != nil: + return ObjectVersion{}, closeErr + default: + return ObjectVersion{}, errors.New("public object exceeded its limit or changed length during download") + } + } + return ObjectVersion{ETag: response.Header.Get("ETag"), Size: written}, nil +} + +// PutIfAbsent creates an object only when no object already exists at key. +// Unlike PutNoReplace, it never turns an unrelated provider failure into an +// existence result by performing a later HEAD request. +func (c Client) PutIfAbsent(key, local string) (ObjectVersion, error) { + return c.putConditional(key, local, "", ErrExists) +} + +// PutIfMatch replaces an object only when its current ETag is the exact token +// obtained by the caller's earlier versioned read. +func (c Client) PutIfMatch(key, local string, expected ObjectVersion) (ObjectVersion, error) { + if err := validateVersion(expected); err != nil { + return ObjectVersion{}, err + } + return c.putConditional(key, local, expected.ETag, ErrVersionConflict) +} + +func (c Client) putConditional(key, local, match string, conflict error) (ObjectVersion, error) { + if c.PublicBaseURL != "" { + return ObjectVersion{}, errors.New("conditional writes require authenticated object storage") + } + if err := validateKey(key); err != nil { + return ObjectVersion{}, err + } + info, err := os.Lstat(local) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return ObjectVersion{}, errors.New("conditional upload source must be a regular non-symlink file") + } + args := []string{"put-object", "--bucket", c.Bucket, "--key", key, "--body", local} + if match == "" { + args = append(args, "--if-none-match", "*") + } else { + args = append(args, "--if-match", match) + } + args = append(args, "--output", "json") + raw, err := c.run(args...) + if err != nil { + if conditionalFailure(err) { + return ObjectVersion{}, fmt.Errorf("%w: conditional object write was refused", conflict) + } + return ObjectVersion{}, err + } + var response struct { + ETag string `json:"ETag"` + VersionID string `json:"VersionId"` + } + if err := json.Unmarshal(raw, &response); err != nil { + return ObjectVersion{}, errors.New("object storage returned invalid write metadata") + } + version := ObjectVersion{ETag: response.ETag, VersionID: response.VersionID, Size: info.Size()} + if err := validateVersion(version); err != nil { + return ObjectVersion{}, err + } + return version, nil +} + +func conditionalFailure(err error) bool { + message := strings.ToLower(err.Error()) + return strings.Contains(message, "preconditionfailed") || + strings.Contains(message, "conditionalrequestconflict") || + strings.Contains(message, "precondition failed") || + strings.Contains(message, "http 409") || strings.Contains(message, "http 412") || + strings.Contains(message, "status code: 409") || strings.Contains(message, "status code: 412") +} diff --git a/internal/store/conditional_test.go b/internal/store/conditional_test.go new file mode 100644 index 0000000..36ee876 --- /dev/null +++ b/internal/store/conditional_test.go @@ -0,0 +1,206 @@ +package store + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func installConditionalAWS(t *testing.T, script string) string { + t.Helper() + dir := t.TempDir() + executable := filepath.Join(dir, "aws") + if err := os.WriteFile(executable, []byte("#!/bin/sh\n"+script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return dir +} + +func TestGetVersionedAtMostPinsExactProviderVersion(t *testing.T) { + dir := installConditionalAWS(t, ` +printf '%s\n' "$*" >> "$RELAY_TEST_CALLS" +case " $* " in + *" head-object "*) printf '%s\n' '{"ETag":"\"root-v1\"","VersionId":"version-1","ContentLength":4}' ;; + *" get-object "*) + for destination do :; done + printf root > "$destination" + printf '%s\n' '{"ETag":"\"root-v1\"","VersionId":"version-1"}' + ;; +esac +`) + calls := filepath.Join(dir, "calls") + t.Setenv("RELAY_TEST_CALLS", calls) + out := filepath.Join(dir, "root.json") + version, err := (Client{Bucket: "published"}).GetVersionedAtMost("state/id/root.json", out, 1024) + if err != nil { + t.Fatal(err) + } + if version.ETag != `"root-v1"` || version.VersionID != "version-1" || version.Size != 4 { + t.Fatalf("version = %#v", version) + } + raw, err := os.ReadFile(out) + if err != nil || string(raw) != "root" { + t.Fatalf("download = %q (%v)", raw, err) + } + log, err := os.ReadFile(calls) + if err != nil { + t.Fatal(err) + } + text := string(log) + if !strings.Contains(text, `--if-match "root-v1"`) || !strings.Contains(text, "--version-id version-1") { + t.Fatalf("download was not pinned to the HEAD result: %s", text) + } +} + +func TestGetVersionedAtMostRejectsOversizeBeforeDownload(t *testing.T) { + dir := installConditionalAWS(t, ` +printf '%s\n' "$*" >> "$RELAY_TEST_CALLS" +printf '%s\n' '{"ETag":"\"large\"","ContentLength":1025}' +`) + calls := filepath.Join(dir, "calls") + t.Setenv("RELAY_TEST_CALLS", calls) + out := filepath.Join(dir, "root.json") + if _, err := (Client{Bucket: "published"}).GetVersionedAtMost("state/id/root.json", out, 1024); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("oversize error = %v", err) + } + log, _ := os.ReadFile(calls) + if strings.Contains(string(log), "get-object") { + t.Fatalf("oversize object was downloaded: %s", log) + } + if _, err := os.Lstat(out); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("oversize download left output: %v", err) + } +} + +func TestGetVersionedAtMostMapsReadRaceToConflict(t *testing.T) { + dir := installConditionalAWS(t, ` +case " $* " in + *" head-object "*) printf '%s\n' '{"ETag":"\"old\"","ContentLength":4}' ;; + *) printf '%s\n' 'PreconditionFailed: status code: 412' >&2; exit 1 ;; +esac +`) + out := filepath.Join(dir, "root.json") + _, err := (Client{Bucket: "published"}).GetVersionedAtMost("state/id/root.json", out, 1024) + if !errors.Is(err, ErrVersionConflict) { + t.Fatalf("read race error = %v", err) + } + if _, statErr := os.Lstat(out); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("failed read left output: %v", statErr) + } +} + +func TestConditionalWritesUseDistinctConflictErrors(t *testing.T) { + dir := installConditionalAWS(t, ` +printf '%s\n' "$*" >> "$RELAY_TEST_CALLS" +printf '%s\n' 'PreconditionFailed: status code: 412' >&2 +exit 1 +`) + calls := filepath.Join(dir, "calls") + t.Setenv("RELAY_TEST_CALLS", calls) + local := filepath.Join(dir, "new-root") + if err := os.WriteFile(local, []byte("root"), 0o600); err != nil { + t.Fatal(err) + } + client := Client{Bucket: "published"} + if _, err := client.PutIfAbsent("state/id/root.json", local); !errors.Is(err, ErrExists) || errors.Is(err, ErrVersionConflict) { + t.Fatalf("create conflict = %v", err) + } + if _, err := client.PutIfMatch("state/id/root.json", local, ObjectVersion{ETag: `"old"`, Size: 4}); !errors.Is(err, ErrVersionConflict) || errors.Is(err, ErrExists) { + t.Fatalf("replace conflict = %v", err) + } + log, _ := os.ReadFile(calls) + if !strings.Contains(string(log), "--if-none-match *") || !strings.Contains(string(log), `--if-match "old"`) { + t.Fatalf("conditional flags missing: %s", log) + } +} + +func TestConditionalWriteDoesNotConvertUnrelatedFailure(t *testing.T) { + dir := installConditionalAWS(t, ` +printf '%s\n' 'connection reset' >&2 +exit 1 +`) + local := filepath.Join(dir, "new-root") + if err := os.WriteFile(local, []byte("root"), 0o600); err != nil { + t.Fatal(err) + } + _, err := (Client{Bucket: "published"}).PutIfAbsent("state/id/root.json", local) + if err == nil || errors.Is(err, ErrExists) || errors.Is(err, ErrVersionConflict) { + t.Fatalf("unrelated failure was misclassified: %v", err) + } +} + +func TestConditionalWriteReturnsNewVersion(t *testing.T) { + dir := installConditionalAWS(t, ` +printf '%s\n' '{"ETag":"\"new\"","VersionId":"version-2"}' +`) + local := filepath.Join(dir, "new-root") + if err := os.WriteFile(local, []byte("root"), 0o600); err != nil { + t.Fatal(err) + } + version, err := (Client{Bucket: "published"}).PutIfMatch("state/id/root.json", local, ObjectVersion{ETag: `"old"`, Size: 3}) + if err != nil { + t.Fatal(err) + } + if version.ETag != `"new"` || version.VersionID != "version-2" || version.Size != 4 { + t.Fatalf("new version = %#v", version) + } +} + +func TestPublicVersionedReadIsStreamBounded(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", `"public"`) + _, _ = w.Write([]byte("12345")) + })) + defer server.Close() + dir := t.TempDir() + out := filepath.Join(dir, "root.json") + client := Client{PublicBaseURL: server.URL, httpClient: server.Client()} + if _, err := client.GetVersionedAtMost("state/id/root.json", out, 4); err == nil { + t.Fatal("oversize public response was accepted") + } + if _, err := os.Lstat(out); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("oversize public response left output: %v", err) + } +} + +func TestPublicVersionedReadRefusesCrossOriginRedirect(t *testing.T) { + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("root")) + })) + defer target.Close() + source := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/root", http.StatusFound) + })) + defer source.Close() + dir := t.TempDir() + client := Client{PublicBaseURL: source.URL, httpClient: source.Client()} + if _, err := client.GetVersionedAtMost("state/id/root.json", filepath.Join(dir, "root.json"), 1024); err == nil || !strings.Contains(err.Error(), "redirect") { + t.Fatalf("cross-origin redirect error = %v", err) + } +} + +func TestPublicVersionedReadCancelsStalledResponse(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer server.Close() + previous := publicReadTimeout + publicReadTimeout = 20 * time.Millisecond + t.Cleanup(func() { publicReadTimeout = previous }) + + start := time.Now() + client := Client{PublicBaseURL: server.URL, httpClient: server.Client()} + _, err := client.GetVersionedAtMost("state/id/root.json", filepath.Join(t.TempDir(), "root.json"), 1024) + if err == nil { + t.Fatal("stalled public response was accepted") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("stalled public response was not cancelled promptly: %s", elapsed) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 9e93bae..aedba4f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -41,6 +41,7 @@ type Client struct { PublicBaseURL string Credentials *Credentials NoSign bool + httpClient *http.Client } // Key returns the content-addressed object key for a tagged sha256 digest. diff --git a/internal/transcript/checkpoint_test.go b/internal/transcript/checkpoint_test.go new file mode 100644 index 0000000..a92c238 --- /dev/null +++ b/internal/transcript/checkpoint_test.go @@ -0,0 +1,110 @@ +package transcript + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func checkpointInspectionFixture() CheckpointInspection { + digest := Digest{SHA256: "sha256:" + strings.Repeat("a", 64), Blake2b256: "blake2b256:" + strings.Repeat("b", 64), Size: 10} + artifact := ArtifactRef{Name: "phase1/genesis.bin", Digest: digest} + definition := SignedArtifactRefs{Record: ArtifactRef{Name: "ceremony.json", Digest: digest}, Signature: ArtifactRef{Name: "ceremony.sig", Digest: digest}} + return CheckpointInspection{ + Schema: "proof-tool-mpc-checkpoint-inspection-v1", CeremonyID: "sha256:" + strings.Repeat("c", 64), + Workflow: "storage-first-v1", RelayReleaseID: "role-images-release", Sequence: 0, Digest: digest, + Transition: CheckpointTransition{Kind: "initial"}, + Definition: definition, + Phase1: CheckpointPhaseState{Phase: "phase1", HeadRecordID: "sha256:" + strings.Repeat("d", 64), HeadPayload: artifact, + Chain: SignedArtifactRefs{Record: ArtifactRef{Name: "phase1/chain-0000.json", Digest: digest}, Signature: ArtifactRef{Name: "phase1/chain-0000.sig", Digest: digest}}}, + AcceptedArtifacts: []ArtifactRef{artifact}, + } +} + +func TestCheckpointUsesProofToolProjection(t *testing.T) { + want := checkpointInspectionFixture() + digest := want.Digest + want.Phase1Closure = &SignedArtifactRefs{Record: ArtifactRef{Name: "phase1/close.json", Digest: digest}, Signature: ArtifactRef{Name: "phase1/close.sig", Digest: digest}} + want.Phase1Beacon = &SignedArtifactRefs{Record: ArtifactRef{Name: "phase1/beacon.json", Digest: digest}, Signature: ArtifactRef{Name: "phase1/beacon.sig", Digest: digest}} + want.Phase1Seal = &SignedArtifactRefs{Record: ArtifactRef{Name: "phase1/seal.json", Digest: digest}, Signature: ArtifactRef{Name: "phase1/seal.sig", Digest: digest}} + want.Phase2 = &CheckpointPhaseState{Phase: "phase2", HeadRecordID: "sha256:" + strings.Repeat("e", 64), HeadPayload: ArtifactRef{Name: "phase2/genesis.bin", Digest: digest}, Chain: SignedArtifactRefs{Record: ArtifactRef{Name: "phase2/chain-0000.json", Digest: digest}, Signature: ArtifactRef{Name: "phase2/chain-0000.sig", Digest: digest}}} + want.Phase2Closure = &SignedArtifactRefs{Record: ArtifactRef{Name: "phase2/close.json", Digest: digest}, Signature: ArtifactRef{Name: "phase2/close.sig", Digest: digest}} + want.Phase2Beacon = &SignedArtifactRefs{Record: ArtifactRef{Name: "phase2/beacon.json", Digest: digest}, Signature: ArtifactRef{Name: "phase2/beacon.sig", Digest: digest}} + want.FinalCandidate = &SignedArtifactRefs{Record: ArtifactRef{Name: "final/candidate.json", Digest: digest}, Signature: ArtifactRef{Name: "final/candidate.sig", Digest: digest}} + want.FinalRelease = &SignedArtifactRefs{Record: ArtifactRef{Name: "final/release.json", Digest: digest}, Signature: ArtifactRef{Name: "final/release.sig", Digest: digest}} + inspector := Inspector{CeremonyPath: "ceremony.json", CeremonySignaturePath: "ceremony.sig", CoordinatorPublicKeyPath: "coordinator.hex"} + inspector.Runner = func(_ string, args ...string) ([]byte, []byte, error) { + if strings.Join(args, " ") != "--format json inspect checkpoint --ceremony ceremony.json --ceremony-signature ceremony.sig --coordinator-public-key-file coordinator.hex --checkpoint checkpoint.json --checkpoint-signature checkpoint.sig" { + t.Fatalf("args=%q", args) + } + result := inspectionResult{Schema: commandResultSchema, OK: true, Command: "inspect checkpoint", CheckpointInspection: &want} + raw, _ := json.Marshal(result) + return raw, nil, nil + } + got, err := inspector.Checkpoint("checkpoint.json", "checkpoint.sig") + if err != nil || got.Sequence != 0 || got.Workflow != "storage-first-v1" || got.Phase2 == nil || got.FinalRelease == nil { + t.Fatalf("got=%+v err=%v", got, err) + } +} + +func TestCheckpointRejectsMissingOrMalformedProjection(t *testing.T) { + inspector := Inspector{CeremonyPath: "ceremony.json", CeremonySignaturePath: "ceremony.sig", CoordinatorPublicKeyPath: "coordinator.hex"} + inspector.Runner = func(_ string, _ ...string) ([]byte, []byte, error) { + result := inspectionResult{Schema: commandResultSchema, OK: true, Command: "inspect checkpoint"} + raw, _ := json.Marshal(result) + return raw, nil, nil + } + if _, err := inspector.Checkpoint("checkpoint.json", "checkpoint.sig"); err == nil { + t.Fatal("missing projection accepted") + } + bad := checkpointInspectionFixture() + bad.AcceptedArtifacts[0].Name = "../escape" + inspector.Runner = func(_ string, _ ...string) ([]byte, []byte, error) { + result := inspectionResult{Schema: commandResultSchema, OK: true, Command: "inspect checkpoint", CheckpointInspection: &bad} + raw, _ := json.Marshal(result) + return raw, nil, nil + } + if _, err := inspector.Checkpoint("checkpoint.json", "checkpoint.sig"); err == nil { + t.Fatal("unsafe proof-tool projection accepted") + } +} + +func TestCheckpointTransitionPropagatesProofToolRejection(t *testing.T) { + inspector := Inspector{CeremonyPath: "ceremony.json", CeremonySignaturePath: "ceremony.sig", CoordinatorPublicKeyPath: "coordinator.hex"} + inspector.Runner = func(_ string, _ ...string) ([]byte, []byte, error) { + result := inspectionResult{Schema: commandResultSchema, OK: false, Command: "inspect checkpoint-transition"} + result.Error.Message = "checkpoint parent mismatch" + raw, _ := json.Marshal(result) + return raw, nil, errors.New("exit 6") + } + if _, err := inspector.CheckpointTransition("p.json", "p.sig", "n.json", "n.sig"); err == nil || !strings.Contains(err.Error(), "parent mismatch") { + t.Fatalf("err=%v", err) + } +} + +func TestCheckpointEvidenceRequiresFullVerifiedProjection(t *testing.T) { + inspector := Inspector{CeremonyPath: "ceremony.json", CeremonySignaturePath: "ceremony.sig", CoordinatorPublicKeyPath: "coordinator.hex"} + want := CheckpointEvidenceInspection{ + Schema: "proof-tool-mpc-checkpoint-evidence-inspection-v1", CeremonyID: "sha256:" + strings.Repeat("c", 64), + Sequence: 3, CheckpointDigest: Digest{SHA256: "sha256:" + strings.Repeat("a", 64), Size: 10}, + TransitionKind: "phase1-candidate-accepted", FullyVerified: true, VerifiedEvidenceBoundary: "cp0-cp3", + } + inspector.Runner = func(_ string, args ...string) ([]byte, []byte, error) { + if strings.Join(args, " ") != "--format json checkpoint verify-stored --ceremony ceremony.json --ceremony-signature ceremony.sig --coordinator-public-key-file coordinator.hex --artifact-root artifacts --checkpoint checkpoint.json --checkpoint-signature checkpoint.sig" { + t.Fatalf("args=%q", args) + } + result := inspectionResult{Schema: commandResultSchema, OK: true, Command: "checkpoint verify-stored", CheckpointEvidenceInspection: &want} + raw, _ := json.Marshal(result) + return raw, nil, nil + } + got, err := inspector.CheckpointEvidence("artifacts", "checkpoint.json", "checkpoint.sig") + if err != nil || !got.FullyVerified || got.Sequence != 3 { + t.Fatalf("got=%+v err=%v", got, err) + } + + want.FullyVerified = false + if _, err := inspector.CheckpointEvidence("artifacts", "checkpoint.json", "checkpoint.sig"); err == nil { + t.Fatal("non-full evidence projection accepted") + } +} diff --git a/internal/transcript/commitments_v4.go b/internal/transcript/commitments_v4.go new file mode 100644 index 0000000..5d245b8 --- /dev/null +++ b/internal/transcript/commitments_v4.go @@ -0,0 +1,261 @@ +package transcript + +import ( + "errors" + "fmt" + "strings" + "time" +) + +// Commitments locate signed records in verified checkpoint ancestry. They do +// not assert those record contents or their named payloads were verified. +type CheckpointCommitmentsV4 struct { + Enrollments []SignedArtifactRefs `json:"enrollments"` + Turns []TurnCommitmentV4 `json:"turns"` + FinalReleaseArtifacts []ArtifactRef `json:"final_release_artifacts"` +} + +type CandidateAllocationV4 struct { + CheckpointSequence uint64 `json:"checkpoint_sequence"` + Checkpoint SignedArtifactRefs `json:"checkpoint"` + AttemptID string `json:"attempt_id"` + AllocatedAt string `json:"allocated_at"` +} + +// Receipt-era fields are retained only so an interrupted development build can +// be inspected. The released V4 validator below rejects them. +type OutboundCommitmentV4 struct { + CheckpointSequence uint64 `json:"checkpoint_sequence"` + PublishedAttemptID string `json:"published_attempt_id"` + Pair SignedArtifactRefs `json:"pair"` +} + +type AcceptedTurnRecordV4 struct { + AttemptID string `json:"attempt_id"` + Pair SignedArtifactRefs `json:"pair"` +} + +type AcceptedChainCommitmentV4 struct { + AttemptID string `json:"attempt_id"` + ContributionResultID string `json:"contribution_result_id"` + Pair SignedArtifactRefs `json:"pair"` +} + +type TurnCommitmentV4 struct { + Scope ContributionScopeV4 `json:"scope"` + // Newest first. Retired attempts remain visible, while only one matching + // delivery slot may be active. + Allocations []CandidateAllocationV4 `json:"allocations"` + AcceptedChain *AcceptedChainCommitmentV4 `json:"accepted_chain,omitempty"` + Outbounds []OutboundCommitmentV4 `json:"outbounds,omitempty"` + InputReceipt *AcceptedTurnRecordV4 `json:"input_receipt,omitempty"` + ReturnHandoff *SignedArtifactRefs `json:"return_handoff,omitempty"` + ReturnReceipt *SignedArtifactRefs `json:"return_receipt,omitempty"` +} + +func validateCommitmentsV4(c CheckpointStateV4, index CheckpointCommitmentsV4) error { + if index.Enrollments == nil || len(index.Enrollments) > 128 || index.Turns == nil || len(index.Turns) > 40 || len(index.FinalReleaseArtifacts) > 2053 { + return errors.New("missing or oversized commitment index") + } + if (c.Progress.FinalRelease != nil) != (len(index.FinalReleaseArtifacts) != 0) { + return errors.New("final release download inventory does not match ceremony progress") + } + lastArtifact := "" + for _, ref := range index.FinalReleaseArtifacts { + if !strings.HasPrefix(ref.Name, "final/release/") { + return errors.New("final release download inventory escapes its namespace") + } + if err := validateBoundedRefV4(ref, 16<<30); err != nil { + return err + } + if ref.Name <= lastArtifact { + return errors.New("final release download inventory must be sorted and unique") + } + lastArtifact = ref.Name + } + last := "" + for _, pair := range index.Enrollments { + if err := validatePairV4(pair); err != nil { + return err + } + if pair.Record.Name <= last { + return errors.New("unordered or duplicate enrollment commitment") + } + last = pair.Record.Name + } + last = "" + for _, turn := range index.Turns { + if len(turn.Outbounds) != 0 || turn.InputReceipt != nil || turn.ReturnHandoff != nil || turn.ReturnReceipt != nil { + return errors.New("receipt-era turn commitments are not valid in v4") + } + s := turn.Scope + if s.CeremonyID != c.CeremonyID || (s.Phase != "phase1" && s.Phase != "phase2") || s.Index == 0 || s.Index > 20 || s.ParticipantID == "" || !taggedHash(s.ParentHeadID, "sha256:") { + return errors.New("invalid turn commitment scope") + } + key := fmt.Sprintf("%s/%02d", s.Phase, s.Index) + if key <= last { + return errors.New("unordered or duplicate turn commitment") + } + last = key + if turn.Allocations == nil || len(turn.Allocations) == 0 || len(turn.Allocations) > 16 { + return errors.New("invalid allocation commitment history") + } + previousSequence := c.Sequence + 1 + for _, allocation := range turn.Allocations { + if allocation.CheckpointSequence >= previousSequence || allocation.CheckpointSequence == 0 { + return errors.New("invalid candidate allocation order") + } + previousSequence = allocation.CheckpointSequence + if err := validatePairV4(allocation.Checkpoint); err != nil { + return err + } + if !commitmentAttemptV4(c, s, allocation.AttemptID, false) { + return errors.New("candidate allocation has no matching delivery") + } + if _, err := time.Parse(time.RFC3339Nano, allocation.AllocatedAt); err != nil { + return errors.New("candidate allocation has invalid time") + } + } + if r := turn.AcceptedChain; r != nil { + if err := validatePairV4(r.Pair); err != nil { + return err + } + matched := false + for _, slot := range c.Deliveries { + if slot.Scope == s && slot.AttemptID == r.AttemptID && slot.Kind == "candidate" && slot.Status == "accepted" && slot.ContributionResultID == r.ContributionResultID && taggedHash(r.ContributionResultID, "sha256:") { + matched = true + } + } + if !matched { + return errors.New("accepted chain has no matching candidate result") + } + } + } + return nil +} + +func commitmentAttemptV4(c CheckpointStateV4, scope ContributionScopeV4, id string, accepted bool) bool { + for _, slot := range c.Deliveries { + if slot.Scope == scope && slot.AttemptID == id && slot.Kind == "candidate" && (!accepted || slot.Status == "accepted") { + return true + } + } + return false +} + +type CommittedEnrollmentMetadataV4 struct { + Refs SignedArtifactRefs `json:"refs"` + // Only the public fields needed by guidance; parsed from approved-tool + // output, never directly from a storage enrollment file. + Enrollment EnrollmentInspection `json:"enrollment"` +} + +type EnrollmentMetadataV4 struct { + CeremonyID string `json:"ceremony_id"` + Checkpoint SignedArtifactRefs `json:"checkpoint"` + Enrollments []CommittedEnrollmentMetadataV4 `json:"enrollments"` +} + +type EnrollmentMetadataInspectionV4 struct { + Schema string `json:"schema"` + Depth string `json:"depth"` + Metadata EnrollmentMetadataV4 `json:"metadata"` + EnrollmentSignaturesVerified bool `json:"enrollment_signatures_verified"` + DisclosureContentsVerified bool `json:"disclosure_contents_verified"` + CompleteRosterVerified bool `json:"complete_roster_verified"` + GlobalFreshnessVerified bool `json:"global_freshness_verified"` +} + +func (i Inspector) CheckpointEnrollmentsV4(root, record, signature string) (EnrollmentMetadataInspectionV4, error) { + r, err := i.checkpointV4("inspect-enrollments-v4", root, record, signature) + if err != nil { + return EnrollmentMetadataInspectionV4{}, err + } + if r.Command != "checkpoint inspect-enrollments-v4" || r.EnrollmentMetadataV4 == nil { + return EnrollmentMetadataInspectionV4{}, errors.New("missing committed enrollment inspection") + } + return validateEnrollmentMetadataV4(*r.EnrollmentMetadataV4) +} + +// One approved command verifies ancestry and enrollment signatures together. +// Distinct projections keep structural and record verification claims separate. +func (i Inspector) CheckpointGuidanceV4(root, record, signature string) (CheckpointInspectionV4, EnrollmentMetadataInspectionV4, error) { + r, err := i.checkpointV4("inspect-enrollments-v4", root, record, signature) + if err != nil { + return CheckpointInspectionV4{}, EnrollmentMetadataInspectionV4{}, err + } + if r.Command != "checkpoint inspect-enrollments-v4" || r.CheckpointInspectionV4 == nil || r.EnrollmentMetadataV4 == nil { + return CheckpointInspectionV4{}, EnrollmentMetadataInspectionV4{}, errors.New("missing combined checkpoint guidance inspection") + } + c, err := validateStoredInspectionV4(*r.CheckpointInspectionV4) + if err != nil { + return CheckpointInspectionV4{}, EnrollmentMetadataInspectionV4{}, err + } + e, err := validateEnrollmentMetadataV4(*r.EnrollmentMetadataV4) + if err != nil { + return CheckpointInspectionV4{}, EnrollmentMetadataInspectionV4{}, err + } + if e.Metadata.CeremonyID != c.Checkpoint.CeremonyID || e.Metadata.Checkpoint != c.CheckpointRefs || len(e.Metadata.Enrollments) != len(c.Commitments.Enrollments) { + return CheckpointInspectionV4{}, EnrollmentMetadataInspectionV4{}, errors.New("guidance metadata differs from checkpoint") + } + for n, item := range e.Metadata.Enrollments { + if item.Refs != c.Commitments.Enrollments[n] { + return CheckpointInspectionV4{}, EnrollmentMetadataInspectionV4{}, errors.New("guidance enrollment set differs from checkpoint") + } + } + return c, e, nil +} + +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") + } + if err := validatePairV4(p.Metadata.Checkpoint); err != nil { + return EnrollmentMetadataInspectionV4{}, err + } + last := "" + seen := map[string]bool{} + for _, item := range p.Metadata.Enrollments { + if err := validatePairV4(item.Refs); err != nil { + return EnrollmentMetadataInspectionV4{}, err + } + if item.Refs.Record.Name <= last { + return EnrollmentMetadataInspectionV4{}, errors.New("unordered or duplicate enrollment metadata") + } + last = item.Refs.Record.Name + e := item.Enrollment + if e.Schema != "proof-tool-mpc-enrollment-record-v1" || e.CeremonyID != p.Metadata.CeremonyID || e.Identity.ID == "" || e.Identity.DisplayName == "" || !protocolID(e.Identity.KeyID) || !taggedHash(e.Identity.PublicKeyFingerprint, "sha256:") || !taggedHash("ed25519:"+e.Identity.Ed25519PublicKeyHex, "ed25519:") || e.RoleIndex < 1 || e.RoleIndex > 20 || e.EnrolledAt == "" { + return EnrollmentMetadataInspectionV4{}, errors.New("invalid committed enrollment metadata") + } + switch e.Role { + case "coordinator", "release-signer", "auditor", "participant", "public-witness", "mirror-operator": + default: + return EnrollmentMetadataInspectionV4{}, errors.New("invalid committed enrollment role") + } + for _, key := range []string{"id:" + e.Identity.ID, "key:" + e.Identity.KeyID, "fingerprint:" + e.Identity.PublicKeyFingerprint, fmt.Sprintf("assignment:%s/%d", e.Role, e.RoleIndex)} { + if seen[key] { + return EnrollmentMetadataInspectionV4{}, errors.New("duplicate committed enrollment identity or assignment") + } + seen[key] = true + } + if err := validateBoundedRefV4(e.IndependenceDisclosure, 1<<20); err != nil { + return EnrollmentMetadataInspectionV4{}, err + } + } + return p, nil +} + +// Key IDs are authenticated labels, not necessarily fingerprints. Match the +// proof-tool protocol's ID grammar instead of imposing an ed25519: +// convention that the signed ceremony format does not require. +func protocolID(value string) bool { + if value == "" || len(value) > 128 { + return false + } + for _, r := range value { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' && r != '_' && r != '.' && r != ':' { + return false + } + } + return true +} diff --git a/internal/transcript/commitments_v4_test.go b/internal/transcript/commitments_v4_test.go new file mode 100644 index 0000000..d692e7c --- /dev/null +++ b/internal/transcript/commitments_v4_test.go @@ -0,0 +1,98 @@ +package transcript + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestCheckpointEnrollmentMetadataBoundaryV4(t *testing.T) { + pair := SignedArtifactRefs{Record: inspectionTestRef("enrollment.json"), Signature: inspectionTestRef("enrollment.sig")} + e := EnrollmentInspection{Schema: "proof-tool-mpc-enrollment-record-v1", CeremonyID: "sha256:" + hex64, Role: "participant", RoleIndex: 1, EnrolledAt: "2026-09-01T00:00:00Z", IndependenceDisclosure: inspectionTestRef("disclosure.txt"), Identity: PublicIdentity{ID: "person", DisplayName: "Person", KeyID: "ed25519:" + hex64, Ed25519PublicKeyHex: hex64, PublicKeyFingerprint: "sha256:" + hex64}} + p := EnrollmentMetadataInspectionV4{Schema: "proof-tool-mpc-enrollment-metadata-v4", Depth: "committed-enrollment-signatures", EnrollmentSignaturesVerified: true, Metadata: EnrollmentMetadataV4{CeremonyID: e.CeremonyID, Checkpoint: pair, Enrollments: []CommittedEnrollmentMetadataV4{{Refs: pair, Enrollment: e}}}} + check := func(p EnrollmentMetadataInspectionV4) error { + i := testInspector() + i.run = inspectionTestRunner(t, inspectionResult{Schema: commandResultSchema, OK: true, Command: "checkpoint inspect-enrollments-v4", EnrollmentMetadataV4: &p}, "checkpoint inspect-enrollments-v4") + _, err := i.CheckpointEnrollmentsV4("/stage", "/stage/checkpoint.json", "/stage/checkpoint.sig") + return err + } + if err := check(p); err != nil { + t.Fatal(err) + } + plainKeyID := p + plainKeyID.Metadata.Enrollments = append([]CommittedEnrollmentMetadataV4(nil), p.Metadata.Enrollments...) + plainKeyID.Metadata.Enrollments[0].Enrollment.Identity.KeyID = "participant-key" + if err := check(plainKeyID); err != nil { + t.Fatalf("proof-tool-compatible key ID rejected: %v", err) + } + for name, change := range map[string]func(*EnrollmentMetadataInspectionV4){ + "signature unchecked": func(p *EnrollmentMetadataInspectionV4) { p.EnrollmentSignaturesVerified = false }, + "disclosure overclaim": func(p *EnrollmentMetadataInspectionV4) { p.DisclosureContentsVerified = true }, + "roster overclaim": func(p *EnrollmentMetadataInspectionV4) { p.CompleteRosterVerified = true }, + "freshness overclaim": func(p *EnrollmentMetadataInspectionV4) { p.GlobalFreshnessVerified = true }, + "nil set": func(p *EnrollmentMetadataInspectionV4) { p.Metadata.Enrollments = nil }, + "duplicate": func(p *EnrollmentMetadataInspectionV4) { + p.Metadata.Enrollments = append(p.Metadata.Enrollments, p.Metadata.Enrollments[0]) + }, + "wrong ceremony": func(p *EnrollmentMetadataInspectionV4) { p.Metadata.Enrollments[0].Enrollment.CeremonyID = "other" }, + "role": func(p *EnrollmentMetadataInspectionV4) { p.Metadata.Enrollments[0].Enrollment.Role = "operator" }, + "assignment": func(p *EnrollmentMetadataInspectionV4) { p.Metadata.Enrollments[0].Enrollment.RoleIndex = 21 }, + "invalid key id": func(p *EnrollmentMetadataInspectionV4) { + p.Metadata.Enrollments[0].Enrollment.Identity.KeyID = "Upper Case" + }, + "oversized signature": func(p *EnrollmentMetadataInspectionV4) { p.Metadata.Enrollments[0].Refs.Signature.Digest.Size = 4097 }, + } { + var bad EnrollmentMetadataInspectionV4 + b, _ := json.Marshal(p) + if err := json.Unmarshal(b, &bad); err != nil { + t.Fatal(err) + } + change(&bad) + if err := check(bad); err == nil { + t.Errorf("accepted %s", name) + } + } +} + +func TestTurnCommitmentProjectionRejectsReceiptEraStateV4(t *testing.T) { + pair := func(name string) SignedArtifactRefs { + return SignedArtifactRefs{Record: inspectionTestRef(name + ".json"), Signature: inspectionTestRef(name + ".sig")} + } + scope := ContributionScopeV4{CeremonyID: "sha256:" + hex64, Phase: "phase1", Index: 1, ParticipantID: "person", ParentHeadID: "sha256:" + hex64} + a, b, c := strings.Repeat("ab", 16), strings.Repeat("bc", 16), strings.Repeat("cd", 16) + state := CheckpointStateV4{CeremonyID: scope.CeremonyID, Sequence: 5, Deliveries: []DeliverySlotV4{{Scope: scope, AttemptID: a, Kind: "receipt", Status: "retired"}, {Scope: scope, AttemptID: b, Kind: "receipt", Status: "accepted"}, {Scope: scope, AttemptID: c, Kind: "candidate", Status: "accepted", ContributionResultID: "sha256:" + hex64}}} + returnHandoff, returnReceipt := pair("return-handoff"), pair("return-receipt") + index := CheckpointCommitmentsV4{Enrollments: []SignedArtifactRefs{}, Turns: []TurnCommitmentV4{{Scope: scope, Outbounds: []OutboundCommitmentV4{{CheckpointSequence: 3, PublishedAttemptID: b, Pair: pair("new")}, {CheckpointSequence: 1, PublishedAttemptID: a, Pair: pair("old")}}, InputReceipt: &AcceptedTurnRecordV4{AttemptID: b, Pair: pair("input-receipt")}, AcceptedChain: &AcceptedChainCommitmentV4{AttemptID: c, ContributionResultID: "sha256:" + hex64, Pair: pair("accepted-chain")}, ReturnHandoff: &returnHandoff, ReturnReceipt: &returnReceipt}}} + if err := validateCommitmentsV4(state, index); err == nil { + t.Fatal("receipt-era V4 commitment state accepted") + } +} + +func TestCheckpointGuidanceV4UsesOneApprovedCommand(t *testing.T) { + pair := SignedArtifactRefs{Record: inspectionTestRef("checkpoint.json"), Signature: inspectionTestRef("checkpoint.sig")} + c := CheckpointStateV4{Schema: "proof-tool-mpc-checkpoint-v4", Workflow: "storage-first-v2", ReleaseVerification: "coordinator-full-replay-v1", CeremonyID: "sha256:" + hex64, Definition: pair, AcceptedArtifacts: []ArtifactRef{}, Deliveries: []DeliverySlotV4{}} + c.Progress.Phase1 = CheckpointPhaseState{Phase: "phase1", HeadRecordID: "sha256:" + hex64, HeadPayload: inspectionTestRef("genesis.bin"), Chain: pair} + structure := CheckpointInspectionV4{Schema: "proof-tool-mpc-checkpoint-inspection-v4", Depth: "checkpoint-structure", Checkpoint: c, CheckpointRefs: pair, Commitments: CheckpointCommitmentsV4{Enrollments: []SignedArtifactRefs{}, Turns: []TurnCommitmentV4{}}} + metadata := EnrollmentMetadataInspectionV4{Schema: "proof-tool-mpc-enrollment-metadata-v4", Depth: "committed-enrollment-signatures", EnrollmentSignaturesVerified: true, Metadata: EnrollmentMetadataV4{CeremonyID: c.CeremonyID, Checkpoint: pair, Enrollments: []CommittedEnrollmentMetadataV4{}}} + r := inspectionResult{Schema: commandResultSchema, OK: true, Command: "checkpoint inspect-enrollments-v4", CheckpointInspectionV4: &structure, EnrollmentMetadataV4: &metadata} + i := testInspector() + calls := 0 + i.run = func(_ string, args ...string) ([]byte, []byte, error) { + calls++ + if strings.Join(args[2:4], " ") != "checkpoint inspect-enrollments-v4" { + t.Fatalf("unexpected command %v", args) + } + b, err := json.Marshal(r) + return b, nil, err + } + if _, _, err := i.CheckpointGuidanceV4("/stage", "/stage/checkpoint.json", "/stage/checkpoint.sig"); err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatal("guidance launched more than one ancestry verification") + } + metadata.Metadata.Checkpoint.Signature.Name = "different.sig" + if _, _, err := i.CheckpointGuidanceV4("/stage", "/stage/checkpoint.json", "/stage/checkpoint.sig"); err == nil { + t.Fatal("different metadata head accepted") + } +} diff --git a/internal/transcript/computation_output_v4.go b/internal/transcript/computation_output_v4.go new file mode 100644 index 0000000..6d1c98c --- /dev/null +++ b/internal/transcript/computation_output_v4.go @@ -0,0 +1,48 @@ +package transcript + +import "errors" + +// ComputationOutputFactsV4 describes generated public files before cleanup. +// It is not an upload inventory and intentionally has no candidate result ID. +type ComputationOutputFactsV4 struct { + Scope ContributionScopeV4 `json:"scope"` + Predecessor SignedArtifactRefs `json:"predecessor"` + Files []ArtifactRef `json:"files"` +} + +type ComputationOutputInspectionV4 struct { + Schema string `json:"schema"` + Depth string `json:"depth"` + Output ComputationOutputFactsV4 `json:"output"` + SignaturesVerified bool `json:"signatures_verified"` + PayloadDigestVerified bool `json:"payload_digest_verified"` + CleanupVerified bool `json:"cleanup_verified"` + MathematicsReplayed bool `json:"mathematics_replayed"` + GlobalFreshnessVerified bool `json:"global_freshness_verified"` + PhysicalErasureVerified bool `json:"physical_erasure_verified"` +} + +func (i Inspector) ComputationOutputV4(chain, signature, scopeFile, candidateDir string, expected ContributionScopeV4, predecessor SignedArtifactRefs) (ComputationOutputFactsV4, error) { + var zero ComputationOutputFactsV4 + r, err := i.execute("inspect", "computation-output-v4", "--ceremony", i.CeremonyPath, "--ceremony-signature", i.CeremonySignaturePath, "--coordinator-public-key-file", i.CoordinatorPublicKeyPath, "--transcript-root", i.TranscriptRoot, "--chain", chain, "--chain-signature", signature, "--scope", scopeFile, "--candidate-dir", candidateDir) + if err != nil { + return zero, err + } + if r.Command != "inspect computation-output-v4" || r.ComputationOutputV4 == nil { + return zero, errors.New("missing computation output inspection") + } + p := r.ComputationOutputV4 + if p.Schema != "proof-tool-mpc-computation-output-inspection-v4" || p.Depth != "computation-signatures-and-digests" || !p.SignaturesVerified || !p.PayloadDigestVerified || p.CleanupVerified || p.MathematicsReplayed || p.GlobalFreshnessVerified || p.PhysicalErasureVerified { + return zero, errors.New("invalid computation output verification boundary") + } + if p.Output.Scope != expected || p.Output.Predecessor != predecessor { + return zero, errors.New("generated output differs from expected turn or predecessor") + } + if err := validatePairV4(predecessor); err != nil { + return zero, err + } + if err := validateContributionFilesV4(p.Output.Files, expected, 3); err != nil { + return zero, err + } + return p.Output, nil +} diff --git a/internal/transcript/computation_output_v4_test.go b/internal/transcript/computation_output_v4_test.go new file mode 100644 index 0000000..81814e1 --- /dev/null +++ b/internal/transcript/computation_output_v4_test.go @@ -0,0 +1,50 @@ +package transcript + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestComputationOutputV4ProjectionBoundary(t *testing.T) { + scope := ContributionScopeV4{CeremonyID: "sha256:" + hex64, Phase: "phase1", Index: 1, ParticipantID: "participant", ParentHeadID: "sha256:" + hex64} + pair := SignedArtifactRefs{Record: inspectionTestRef("phase1/chain-0000.json"), Signature: inspectionTestRef("phase1/chain-0000.sig")} + p := ComputationOutputInspectionV4{Schema: "proof-tool-mpc-computation-output-inspection-v4", Depth: "computation-signatures-and-digests", SignaturesVerified: true, PayloadDigestVerified: true, Output: ComputationOutputFactsV4{Scope: scope, Predecessor: pair, Files: []ArtifactRef{inspectionTestRef("attestation.json"), inspectionTestRef("attestation.sig"), inspectionTestRef("contribution.bin")}}} + check := func(p ComputationOutputInspectionV4) (ComputationOutputFactsV4, error) { + i := testInspector() + i.run = inspectionTestRunner(t, inspectionResult{Schema: commandResultSchema, OK: true, Command: "inspect computation-output-v4", ComputationOutputV4: &p}, "inspect computation-output-v4") + return i.ComputationOutputV4("/work/chain.json", "/work/chain.sig", "/work/scope.json", "/work/candidate", scope, pair) + } + if got, err := check(p); err != nil || !reflect.DeepEqual(got, p.Output) { + t.Fatalf("valid output: %v, %v", got, err) + } + for name, mutate := range map[string]func(*ComputationOutputInspectionV4){ + "cleanup claim": func(p *ComputationOutputInspectionV4) { p.CleanupVerified = true }, + "math claim": func(p *ComputationOutputInspectionV4) { p.MathematicsReplayed = true }, + "freshness claim": func(p *ComputationOutputInspectionV4) { p.GlobalFreshnessVerified = true }, + "erasure claim": func(p *ComputationOutputInspectionV4) { p.PhysicalErasureVerified = true }, + "unchecked signature": func(p *ComputationOutputInspectionV4) { p.SignaturesVerified = false }, + "unchecked payload": func(p *ComputationOutputInspectionV4) { p.PayloadDigestVerified = false }, + "other turn": func(p *ComputationOutputInspectionV4) { p.Output.Scope.Index++ }, + "other predecessor": func(p *ComputationOutputInspectionV4) { p.Output.Predecessor.Record.Name = "other.json" }, + "partial": func(p *ComputationOutputInspectionV4) { p.Output.Files = p.Output.Files[:2] }, + "extra file": func(p *ComputationOutputInspectionV4) { + p.Output.Files = append(p.Output.Files, inspectionTestRef("erasure.json")) + }, + "wrong name": func(p *ComputationOutputInspectionV4) { p.Output.Files[0].Name = "signing.hex" }, + "oversize": func(p *ComputationOutputInspectionV4) { p.Output.Files[2].Digest.Size = 16<<30 + 1 }, + } { + t.Run(name, func(t *testing.T) { + encoded, _ := json.Marshal(p) + var bad ComputationOutputInspectionV4 + if err := json.Unmarshal(encoded, &bad); err != nil { + t.Fatal(err) + } + mutate(&bad) + got, err := check(bad) + if err == nil || !reflect.DeepEqual(got, ComputationOutputFactsV4{}) { + t.Fatalf("invalid output accepted: %v, %v", got, err) + } + }) + } +} diff --git a/internal/transcript/contribution_inventory_v4.go b/internal/transcript/contribution_inventory_v4.go new file mode 100644 index 0000000..b710a58 --- /dev/null +++ b/internal/transcript/contribution_inventory_v4.go @@ -0,0 +1,112 @@ +package transcript + +import ( + "errors" + "fmt" + "reflect" +) + +// ErrCandidateInvalidV4 means the approved proof-tool authenticated the +// definition, expected turn and predecessor, then determined that the +// retained candidate's semantic records do not verify. It never represents a +// runtime, trust-anchor, path, I/O, or malformed-command failure. +var ErrCandidateInvalidV4 = errors.New("V4 candidate is invalid") + +type CandidateInventoryV4 struct { + Schema string `json:"schema"` + Scope ContributionScopeV4 `json:"scope"` + Files []ArtifactRef `json:"files"` +} + +type ContributionInventoryFactsV4 struct { + Scope ContributionScopeV4 `json:"scope"` + Predecessor SignedArtifactRefs `json:"predecessor"` + Computed CandidateInventoryV4 `json:"computed"` + ComputedCandidateID string `json:"computed_candidate_id"` + Complete *CandidateInventoryV4 `json:"complete,omitempty"` + CandidateResultID string `json:"candidate_result_id,omitempty"` +} + +type ContributionInventoryInspectionV4 struct { + Schema string `json:"schema"` + Depth string `json:"depth"` + Inventory ContributionInventoryFactsV4 `json:"inventory"` + SignaturesVerified bool `json:"signatures_verified"` + PayloadDigestVerified bool `json:"payload_digest_verified"` + MathematicsReplayed bool `json:"mathematics_replayed"` + GlobalFreshnessVerified bool `json:"global_freshness_verified"` + PhysicalErasureVerified bool `json:"physical_erasure_verified"` +} + +// ContributionInventoryV4 delegates reconstruction to approved proof-tool. The +// expected scope and exact predecessor come from verified state/retained work, +// not from an arbitrary folder. The scope file is checked by the child too. +func (i Inspector) ContributionInventoryV4(chain, signature, scopeFile, candidateDir string, expected ContributionScopeV4, predecessor SignedArtifactRefs) (ContributionInventoryFactsV4, error) { + r, err := i.execute("inspect", "contribution-inventory-v4", "--ceremony", i.CeremonyPath, "--ceremony-signature", i.CeremonySignaturePath, "--coordinator-public-key-file", i.CoordinatorPublicKeyPath, "--transcript-root", i.TranscriptRoot, "--chain", chain, "--chain-signature", signature, "--scope", scopeFile, "--candidate-dir", candidateDir) + if err != nil { + var failure *inspectionExecutionError + if errors.As(err, &failure) && failure.command == "inspect contribution-inventory-v4" && failure.code == "candidate_invalid" { + return ContributionInventoryFactsV4{}, fmt.Errorf("%w: %s", ErrCandidateInvalidV4, failure.message) + } + return ContributionInventoryFactsV4{}, err + } + if r.Command != "inspect contribution-inventory-v4" || r.ContributionInventoryV4 == nil { + return ContributionInventoryFactsV4{}, errors.New("missing contribution inventory inspection") + } + p := *r.ContributionInventoryV4 + if p.Schema != "proof-tool-mpc-contribution-inventory-inspection-v4" || p.Depth != "candidate-signatures-and-digests" || !p.SignaturesVerified || !p.PayloadDigestVerified || p.MathematicsReplayed || p.GlobalFreshnessVerified || p.PhysicalErasureVerified { + return ContributionInventoryFactsV4{}, errors.New("invalid contribution inventory verification boundary") + } + f := p.Inventory + if f.Scope != expected || f.Predecessor != predecessor || !taggedHash(f.ComputedCandidateID, "sha256:") { + return ContributionInventoryFactsV4{}, errors.New("inventory differs from expected turn or predecessor") + } + if err := validatePairV4(f.Predecessor); err != nil { + return ContributionInventoryFactsV4{}, err + } + if err := validateInventoryProjectionV4(f.Computed, expected, 5); err != nil { + return ContributionInventoryFactsV4{}, err + } + if f.Complete == nil { + if f.CandidateResultID != "" { + return ContributionInventoryFactsV4{}, errors.New("final candidate identity without complete inventory") + } + } else { + if err := validateInventoryProjectionV4(*f.Complete, expected, 5); err != nil { + return ContributionInventoryFactsV4{}, err + } + if !taggedHash(f.CandidateResultID, "sha256:") || f.CandidateResultID != f.ComputedCandidateID || !reflect.DeepEqual(f.Computed, *f.Complete) { + return ContributionInventoryFactsV4{}, errors.New("complete inventory differs from the fixed five-file candidate") + } + } + return f, nil +} + +func validateInventoryProjectionV4(i CandidateInventoryV4, expected ContributionScopeV4, count int) error { + if i.Schema != "proof-tool-mpc-candidate-inventory-v1" || i.Scope != expected { + return errors.New("invalid candidate inventory projection") + } + return validateContributionFilesV4(i.Files, expected, count) +} + +func validateContributionFilesV4(files []ArtifactRef, expected ContributionScopeV4, count int) error { + if (count != 3 && count != 5) || len(files) != count || !taggedHash(expected.CeremonyID, "sha256:") || !taggedHash(expected.ParentHeadID, "sha256:") || (expected.Phase != "phase1" && expected.Phase != "phase2") || expected.Index == 0 || expected.Index > 20 || expected.ParticipantID == "" { + return errors.New("invalid contribution file projection") + } + names := []string{"attestation.json", "attestation.sig", "contribution.bin", "erasure.json", "erasure.sig"} + for n, ref := range files { + limit := int64(16 << 20) + if n == 2 { + limit = 16 << 30 // proof-tool MaxArtifactSize + } else if n == 1 || n == 4 || n == 6 { + limit = 4096 + } + if ref.Name != names[n] { + return errors.New("unexpected candidate inventory filename") + } + if err := validateBoundedRefV4(ref, limit); err != nil { + return err + } + } + return nil +} diff --git a/internal/transcript/contribution_inventory_v4_test.go b/internal/transcript/contribution_inventory_v4_test.go new file mode 100644 index 0000000..75a7b80 --- /dev/null +++ b/internal/transcript/contribution_inventory_v4_test.go @@ -0,0 +1,78 @@ +package transcript + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestContributionInventoryV4ProjectionBoundary(t *testing.T) { + scope := ContributionScopeV4{CeremonyID: "sha256:" + hex64, Phase: "phase1", Index: 1, ParticipantID: "participant", ParentHeadID: "sha256:" + hex64} + pair := SignedArtifactRefs{Record: inspectionTestRef("phase1/chain-0000.json"), Signature: inspectionTestRef("phase1/chain-0000.sig")} + computed := CandidateInventoryV4{Schema: "proof-tool-mpc-candidate-inventory-v1", Scope: scope, Files: []ArtifactRef{}} + for _, name := range []string{"attestation.json", "attestation.sig", "contribution.bin", "erasure.json", "erasure.sig"} { + computed.Files = append(computed.Files, inspectionTestRef(name)) + } + complete := computed + complete.Files = append([]ArtifactRef{}, computed.Files...) + p := ContributionInventoryInspectionV4{Schema: "proof-tool-mpc-contribution-inventory-inspection-v4", Depth: "candidate-signatures-and-digests", SignaturesVerified: true, PayloadDigestVerified: true, Inventory: ContributionInventoryFactsV4{Scope: scope, Predecessor: pair, Computed: computed, ComputedCandidateID: "sha256:" + hex64, Complete: &complete, CandidateResultID: "sha256:" + hex64}} + check := func(p ContributionInventoryInspectionV4) error { + i := testInspector() + i.run = inspectionTestRunner(t, inspectionResult{Schema: commandResultSchema, OK: true, Command: "inspect contribution-inventory-v4", ContributionInventoryV4: &p}, "inspect contribution-inventory-v4") + _, err := i.ContributionInventoryV4("/work/chain.json", "/work/chain.sig", "/work/scope.json", "/work/candidate", scope, pair) + return err + } + if err := check(p); err != nil { + t.Fatal(err) + } + for name, change := range map[string]func(*ContributionInventoryInspectionV4){ + "scope": func(p *ContributionInventoryInspectionV4) { p.Inventory.Scope.Index = 2 }, + "predecessor": func(p *ContributionInventoryInspectionV4) { p.Inventory.Predecessor.Record.Name = "other.json" }, + "math": func(p *ContributionInventoryInspectionV4) { p.MathematicsReplayed = true }, + "freshness": func(p *ContributionInventoryInspectionV4) { p.GlobalFreshnessVerified = true }, + "erasure": func(p *ContributionInventoryInspectionV4) { p.PhysicalErasureVerified = true }, + "unchecked": func(p *ContributionInventoryInspectionV4) { p.SignaturesVerified = false }, + "partial": func(p *ContributionInventoryInspectionV4) { + p.Inventory.Complete.Files = p.Inventory.Complete.Files[:4] + }, + "changed-five": func(p *ContributionInventoryInspectionV4) { p.Inventory.Complete.Files[0].Digest.Size++ }, + "different-id": func(p *ContributionInventoryInspectionV4) { + p.Inventory.CandidateResultID = "sha256:" + strings.Repeat("b", 64) + }, + "oversize": func(p *ContributionInventoryInspectionV4) { p.Inventory.Computed.Files[2].Digest.Size = 16<<30 + 1 }, + "name": func(p *ContributionInventoryInspectionV4) { p.Inventory.Computed.Files[0].Name = "private.key" }, + "missing-complete": func(p *ContributionInventoryInspectionV4) { p.Inventory.Complete = nil }, + } { + b, _ := json.Marshal(p) + var bad ContributionInventoryInspectionV4 + if err := json.Unmarshal(b, &bad); err != nil { + t.Fatal(err) + } + change(&bad) + if err := check(bad); err == nil { + t.Errorf("accepted %s", name) + } + } + p.Inventory.Complete = nil + p.Inventory.CandidateResultID = "" + if err := check(p); err != nil { + t.Fatal("computed-only inventory rejected", err) + } +} + +func TestContributionInventoryV4DoesNotClassifyOtherCommandFailures(t *testing.T) { + scope := ContributionScopeV4{CeremonyID: "sha256:" + hex64, Phase: "phase1", Index: 1, ParticipantID: "participant", ParentHeadID: "sha256:" + hex64} + pair := SignedArtifactRefs{Record: inspectionTestRef("phase1/chain-0000.json"), Signature: inspectionTestRef("phase1/chain-0000.sig")} + i := testInspector() + i.run = func(_ string, _ ...string) ([]byte, []byte, error) { + return []byte(`{"schema":"proof-tool-mpc-command-result-v1","ok":false,"command":"inspect definition","error":{"code":"candidate_invalid","message":"unrelated command failure"}}`), nil, errors.New("exit status 6") + } + _, err := i.ContributionInventoryV4("/work/chain.json", "/work/chain.sig", "/work/scope.json", "/work/candidate", scope, pair) + if err == nil { + t.Fatal("accepted an unrelated command failure") + } + if errors.Is(err, ErrCandidateInvalidV4) { + t.Fatalf("wrong command became a candidate rejection decision: %v", err) + } +} diff --git a/internal/transcript/inspect.go b/internal/transcript/inspect.go index 88f90b4..15e8f4b 100644 --- a/internal/transcript/inspect.go +++ b/internal/transcript/inspect.go @@ -15,6 +15,7 @@ const ( chainInspectionSchema = "proof-tool-mpc-chain-inspection-v1" participantInspectionSchema = "proof-tool-mpc-participant-inspection-v1" enrollmentInspectionSchema = "proof-tool-mpc-enrollment-inspection-v1" + submissionInspectionSchema = "proof-tool-mpc-submission-inspection-v1" ) // InspectionRunner invokes the trusted ceremony tool. It is exported so Relay @@ -39,15 +40,148 @@ type Inspector struct { } type inspectionResult struct { - Schema string `json:"schema"` - OK bool `json:"ok"` - Command string `json:"command"` - DefinitionInspection *Definition `json:"definition_inspection"` - ChainInspection *chainInspection `json:"chain_inspection"` - ParticipantInspection *ParticipantInspection `json:"participant_inspection"` - EnrollmentInspection *EnrollmentInspection `json:"enrollment_inspection"` - JourneyInspection *Journey `json:"journey_inspection"` - Error inspectionCommandError `json:"error"` + Schema string `json:"schema"` + OK bool `json:"ok"` + Command string `json:"command"` + DefinitionInspection *Definition `json:"definition_inspection"` + DefinitionProtocolInspection *DefinitionProtocol `json:"definition_protocol_inspection"` + CheckpointDiscoveryV4 *CheckpointDiscoveryV4 `json:"checkpoint_discovery_v4"` + CheckpointInspectionV4 *CheckpointInspectionV4 `json:"checkpoint_inspection_v4"` + EnrollmentMetadataV4 *EnrollmentMetadataInspectionV4 `json:"enrollment_metadata_v4"` + ContributionInventoryV4 *ContributionInventoryInspectionV4 `json:"contribution_inventory_v4"` + ComputationOutputV4 *ComputationOutputInspectionV4 `json:"computation_output_v4"` + ChainInspection *chainInspection `json:"chain_inspection"` + ParticipantInspection *ParticipantInspection `json:"participant_inspection"` + EnrollmentInspection *EnrollmentInspection `json:"enrollment_inspection"` + JourneyInspection *Journey `json:"journey_inspection"` + CheckpointInspection *CheckpointInspection `json:"checkpoint_inspection"` + CheckpointTransitionInspection *CheckpointTransitionInspection `json:"checkpoint_transition_inspection"` + CheckpointEvidenceInspection *CheckpointEvidenceInspection `json:"checkpoint_evidence_inspection"` + SubmissionInspection *SubmissionInspection `json:"submission_inspection"` + Error inspectionCommandError `json:"error"` +} + +// SignedArtifactRefs is the transport projection of a canonical record and +// its detached signature. +type SignedArtifactRefs struct { + Record ArtifactRef `json:"record"` + Signature ArtifactRef `json:"signature"` +} + +type CheckpointTransition struct { + Kind string `json:"kind"` + Phase string `json:"phase"` + Index uint8 `json:"index"` + ParticipantID string `json:"participant_id"` + AttemptID string `json:"attempt_id"` + NextAttemptID string `json:"next_attempt_id"` +} + +type CheckpointPhaseState struct { + Phase string `json:"phase"` + AcceptedCount uint8 `json:"accepted_count"` + HeadRecordID string `json:"head_record_id"` + HeadPayload ArtifactRef `json:"head_payload"` + Chain SignedArtifactRefs `json:"chain"` +} + +type CheckpointSubmissionSlot struct { + Kind string `json:"kind"` + Phase string `json:"phase"` + Index uint8 `json:"index"` + IdentityID string `json:"identity_id"` + AttemptID string `json:"attempt_id"` + ManifestKey string `json:"manifest_key"` + BasisCheckpointSHA256 string `json:"basis_checkpoint_sha256"` + ParentHeadID string `json:"parent_head_id"` + Status string `json:"status"` + Acknowledgement *SignedArtifactRefs `json:"acknowledgement"` +} + +type CheckpointInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Workflow string `json:"workflow"` + RelayReleaseID string `json:"relay_release_id"` + Sequence uint64 `json:"sequence"` + Digest Digest `json:"digest"` + Definition SignedArtifactRefs `json:"definition"` + PreviousCheckpoint *SignedArtifactRefs `json:"previous_checkpoint"` + Transition CheckpointTransition `json:"transition"` + Phase1 CheckpointPhaseState `json:"phase1"` + Phase1Closure *SignedArtifactRefs `json:"phase1_closure,omitempty"` + Phase1Beacon *SignedArtifactRefs `json:"phase1_beacon,omitempty"` + Phase1Seal *SignedArtifactRefs `json:"phase1_seal,omitempty"` + Phase2 *CheckpointPhaseState `json:"phase2,omitempty"` + Phase2Closure *SignedArtifactRefs `json:"phase2_closure,omitempty"` + Phase2Beacon *SignedArtifactRefs `json:"phase2_beacon,omitempty"` + FinalCandidate *SignedArtifactRefs `json:"final_candidate,omitempty"` + FinalRelease *SignedArtifactRefs `json:"final_release,omitempty"` + Submissions []CheckpointSubmissionSlot `json:"submissions"` + AcceptedArtifacts []ArtifactRef `json:"accepted_artifacts"` +} + +type CheckpointTransitionInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + PreviousSequence uint64 `json:"previous_sequence"` + Sequence uint64 `json:"sequence"` + PreviousCheckpointDigest Digest `json:"previous_checkpoint_digest"` + PreviousSignatureDigest Digest `json:"previous_signature_digest"` + CheckpointDigest Digest `json:"checkpoint_digest"` + Transition CheckpointTransition `json:"transition"` + Checkpoint CheckpointInspection `json:"checkpoint"` +} + +// CheckpointEvidenceInspection is emitted only after proof-tool has +// reconstructed the checkpoint from all of its stored evidence. Structural +// checkpoint inspection deliberately does not set this result. +type CheckpointEvidenceInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Sequence uint64 `json:"sequence"` + CheckpointDigest Digest `json:"checkpoint_digest"` + TransitionKind string `json:"transition_kind"` + FullyVerified bool `json:"fully_verified"` + VerifiedEvidenceBoundary string `json:"verified_evidence_boundary"` +} + +// SubmissionInspection is emitted only after proof-tool authenticates the +// participant envelope and binds it to the exact allocated checkpoint slot and +// transport manifest bytes supplied by the caller. +type SubmissionInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Workflow string `json:"workflow"` + RelayReleaseID string `json:"relay_release_id"` + SubmitterID string `json:"submitter_id"` + SubmitterKeyID string `json:"submitter_key_id"` + SubmitterRole string `json:"submitter_role"` + Kind string `json:"kind"` + Phase string `json:"phase"` + Index uint8 `json:"index"` + ParentCheckpointSHA256 string `json:"parent_checkpoint_sha256"` + AllocationCheckpointSHA256 string `json:"allocation_checkpoint_sha256"` + ParentHeadID string `json:"parent_head_id"` + AttemptID string `json:"attempt_id"` + ManifestKey string `json:"manifest_key"` + Payloads []ArtifactRef `json:"payloads"` + EnvelopeDigest Digest `json:"envelope_digest"` + EnvelopeSignatureDigest Digest `json:"envelope_signature_digest"` + ManifestDigest Digest `json:"manifest_digest"` +} + +type SubmissionInspectionPaths struct { + CheckpointPath string + CheckpointSignaturePath string + Kind string + Phase string + Index uint8 + SubmitterID string + AttemptID string + EnvelopePath string + EnvelopeSignaturePath string + ManifestPath string } type PublicIdentity struct { @@ -92,6 +226,19 @@ type inspectionCommandError struct { Message string `json:"message"` } +// inspectionExecutionError preserves a machine-readable failure code from an +// approved proof-tool JSON result. Callers must opt in to a documented code; +// matching diagnostic text is never a protocol decision boundary. +type inspectionExecutionError struct { + command string + code string + message string +} + +func (e *inspectionExecutionError) Error() string { + return "mpc-ceremony inspection failed: " + e.message +} + func (i Inspector) Definition() (Definition, error) { result, err := i.execute( "inspect", "definition", @@ -223,6 +370,135 @@ func (i Inspector) Enrollment(recordPath, signaturePath string) (EnrollmentInspe return inspection, nil } +func (i Inspector) Checkpoint(checkpointPath, signaturePath string) (CheckpointInspection, error) { + result, err := i.execute( + "inspect", "checkpoint", + "--ceremony", i.CeremonyPath, + "--ceremony-signature", i.CeremonySignaturePath, + "--coordinator-public-key-file", i.CoordinatorPublicKeyPath, + "--checkpoint", checkpointPath, + "--checkpoint-signature", signaturePath, + ) + if err != nil { + return CheckpointInspection{}, err + } + if result.Command != "inspect checkpoint" || result.CheckpointInspection == nil { + return CheckpointInspection{}, errors.New("mpc-ceremony returned no checkpoint inspection") + } + inspection := *result.CheckpointInspection + if err := validateCheckpointInspection(inspection); err != nil { + return CheckpointInspection{}, err + } + return inspection, nil +} + +func (i Inspector) CheckpointTransition(previousPath, previousSignaturePath, nextPath, nextSignaturePath string) (CheckpointTransitionInspection, error) { + result, err := i.execute( + "inspect", "checkpoint-transition", + "--ceremony", i.CeremonyPath, + "--ceremony-signature", i.CeremonySignaturePath, + "--coordinator-public-key-file", i.CoordinatorPublicKeyPath, + "--previous-checkpoint", previousPath, + "--previous-checkpoint-signature", previousSignaturePath, + "--checkpoint", nextPath, + "--checkpoint-signature", nextSignaturePath, + ) + if err != nil { + return CheckpointTransitionInspection{}, err + } + if result.Command != "inspect checkpoint-transition" || result.CheckpointTransitionInspection == nil { + return CheckpointTransitionInspection{}, errors.New("mpc-ceremony returned no checkpoint transition inspection") + } + inspection := *result.CheckpointTransitionInspection + if inspection.Schema != "proof-tool-mpc-checkpoint-transition-inspection-v1" || + inspection.CeremonyID == "" || inspection.Sequence != inspection.PreviousSequence+1 { + return CheckpointTransitionInspection{}, errors.New("mpc-ceremony returned an invalid checkpoint transition inspection") + } + if err := validateCheckpointInspection(inspection.Checkpoint); err != nil { + return CheckpointTransitionInspection{}, err + } + return inspection, nil +} + +// CheckpointEvidence asks proof-tool to walk the fetched ancestry and +// re-derive every checkpoint from the exact signed evidence stored under +// artifactRoot. It is the only checkpoint inspection suitable for advancing +// Relay's durable high-water mark. +func (i Inspector) CheckpointEvidence(artifactRoot, checkpointPath, signaturePath string) (CheckpointEvidenceInspection, error) { + result, err := i.execute( + "checkpoint", "verify-stored", + "--ceremony", i.CeremonyPath, + "--ceremony-signature", i.CeremonySignaturePath, + "--coordinator-public-key-file", i.CoordinatorPublicKeyPath, + "--artifact-root", artifactRoot, + "--checkpoint", checkpointPath, + "--checkpoint-signature", signaturePath, + ) + if err != nil { + return CheckpointEvidenceInspection{}, err + } + if result.Command != "checkpoint verify-stored" || result.CheckpointEvidenceInspection == nil { + return CheckpointEvidenceInspection{}, errors.New("mpc-ceremony returned no checkpoint evidence inspection") + } + inspection := *result.CheckpointEvidenceInspection + if inspection.Schema != "proof-tool-mpc-checkpoint-evidence-inspection-v1" || !inspection.FullyVerified || + inspection.CeremonyID == "" || inspection.CheckpointDigest.SHA256 == "" || inspection.TransitionKind == "" || + inspection.VerifiedEvidenceBoundary == "" { + return CheckpointEvidenceInspection{}, errors.New("mpc-ceremony did not fully verify checkpoint evidence") + } + return inspection, nil +} + +func validateCheckpointInspection(value CheckpointInspection) error { + if value.Schema != "proof-tool-mpc-checkpoint-inspection-v1" || value.CeremonyID == "" || + value.Workflow != "storage-first-v1" || value.RelayReleaseID == "" || value.Digest.SHA256 == "" { + return errors.New("mpc-ceremony returned an invalid checkpoint inspection") + } + for _, ref := range append([]ArtifactRef{value.Phase1.HeadPayload, value.Phase1.Chain.Record, value.Phase1.Chain.Signature}, value.AcceptedArtifacts...) { + if err := validateRef(ref); err != nil { + return fmt.Errorf("checkpoint inspection artifact: %w", err) + } + } + if value.Phase2 != nil { + for _, ref := range []ArtifactRef{value.Phase2.HeadPayload, value.Phase2.Chain.Record, value.Phase2.Chain.Signature} { + if err := validateRef(ref); err != nil { + return fmt.Errorf("checkpoint Phase 2 artifact: %w", err) + } + } + } + for label, refs := range map[string]*SignedArtifactRefs{ + "Phase 1 closure": value.Phase1Closure, "Phase 1 beacon": value.Phase1Beacon, + "Phase 1 seal": value.Phase1Seal, "Phase 2 closure": value.Phase2Closure, + "Phase 2 beacon": value.Phase2Beacon, "final candidate": value.FinalCandidate, + "final release": value.FinalRelease, + } { + if refs == nil { + continue + } + if err := validateRef(refs.Record); err != nil { + return fmt.Errorf("checkpoint %s: %w", label, err) + } + if err := validateRef(refs.Signature); err != nil { + return fmt.Errorf("checkpoint %s signature: %w", label, err) + } + } + if err := validateRef(value.Definition.Record); err != nil { + return fmt.Errorf("checkpoint definition: %w", err) + } + if err := validateRef(value.Definition.Signature); err != nil { + return fmt.Errorf("checkpoint definition signature: %w", err) + } + if value.PreviousCheckpoint != nil { + if err := validateRef(value.PreviousCheckpoint.Record); err != nil { + return fmt.Errorf("previous checkpoint: %w", err) + } + if err := validateRef(value.PreviousCheckpoint.Signature); err != nil { + return fmt.Errorf("previous checkpoint signature: %w", err) + } + } + return nil +} + func (i Inspector) execute(args ...string) (inspectionResult, error) { executable := i.Executable if executable == "" { @@ -254,7 +530,7 @@ func (i Inspector) execute(args ...string) (inspectionResult, error) { if message == "" { message = diagnostic(stderr, "inspection failed") } - return inspectionResult{}, fmt.Errorf("mpc-ceremony inspection failed: %s", message) + return inspectionResult{}, &inspectionExecutionError{command: result.Command, code: result.Error.Code, message: message} } return result, nil } diff --git a/internal/transcript/inspect_v4.go b/internal/transcript/inspect_v4.go new file mode 100644 index 0000000..107414e --- /dev/null +++ b/internal/transcript/inspect_v4.go @@ -0,0 +1,502 @@ +package transcript + +import ( + "bytes" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +// These constants match proof-tool's V4 protocol, not the legacy sync limits. +const MaxCheckpointSequenceV4 = 16384 + +type DefinitionProtocol struct { + Schema string `json:"schema"` + DefinitionSchema string `json:"definition_schema"` + StorageWorkflow string `json:"storage_workflow"` + ReleaseVerification string `json:"release_verification"` + Definition Definition `json:"definition"` + DefinitionRefs SignedArtifactRefs `json:"definition_refs"` +} + +func (p DefinitionProtocol) UsesV4() bool { + return p.DefinitionSchema == "proof-tool-mpc-ceremony-definition-v4" && p.StorageWorkflow == "storage-first-v2" && p.ReleaseVerification == "coordinator-full-replay-v1" +} + +// AuthenticateCheckpointForPublicationV4 verifies signed ancestry and asks +// proof-tool to prepare the exact proposal again. prepare-v4 checks the +// transition's evidence and replays contribution mathematics for acceptance +// edges. Relay may publish only when the checked bytes are identical. +func (i Inspector) AuthenticateCheckpointForPublicationV4(root, record, signature string) (CheckpointInspectionV4, error) { + inspection, err := i.StoredCheckpointV4(root, record, signature) + if err != nil { + return CheckpointInspectionV4{}, err + } + temp, err := os.MkdirTemp(root, ".relay-v4-publication-check-") + if err != nil { + return CheckpointInspectionV4{}, err + } + defer os.RemoveAll(temp) + checked := filepath.Join(temp, "checkpoint.json") + result, err := i.execute( + "checkpoint", "prepare-v4", + "--ceremony", i.CeremonyPath, + "--ceremony-signature", i.CeremonySignaturePath, + "--coordinator-public-key-file", i.CoordinatorPublicKeyPath, + "--artifact-root", root, + "--proposal", record, + "--out", checked, + ) + if err != nil { + return CheckpointInspectionV4{}, err + } + if result.Command != "checkpoint prepare-v4" { + return CheckpointInspectionV4{}, errors.New("mpc-ceremony returned the wrong V4 checkpoint authentication result") + } + original, err := os.ReadFile(record) + if err != nil { + return CheckpointInspectionV4{}, err + } + prepared, err := os.ReadFile(checked) + if err != nil { + return CheckpointInspectionV4{}, err + } + if !bytes.Equal(original, prepared) { + return CheckpointInspectionV4{}, errors.New("proof-tool checked different V4 checkpoint bytes") + } + return inspection, nil +} + +// DefinitionProtocol never falls back after a failed inspection. Older pinned +// releases use their existing Definition method, selected before this call. +func (i Inspector) DefinitionProtocol() (DefinitionProtocol, error) { + r, err := i.execute("inspect", "definition-protocol", "--ceremony", i.CeremonyPath, "--ceremony-signature", i.CeremonySignaturePath, "--coordinator-public-key-file", i.CoordinatorPublicKeyPath) + if err != nil { + return DefinitionProtocol{}, err + } + if r.Command != "inspect definition-protocol" || r.DefinitionProtocolInspection == nil { + return DefinitionProtocol{}, errors.New("missing authenticated protocol inspection") + } + p := *r.DefinitionProtocolInspection + if p.Schema != "proof-tool-mpc-definition-protocol-inspection-v1" { + return DefinitionProtocol{}, errors.New("unsupported protocol inspection schema") + } + switch p.DefinitionSchema { + case "proof-tool-mpc-ceremony-definition-v4": + if !p.UsesV4() { + return DefinitionProtocol{}, errors.New("inconsistent V4 protocol selector") + } + if err := validatePairV4(p.DefinitionRefs); err != nil { + return DefinitionProtocol{}, fmt.Errorf("authenticated definition references: %w", err) + } + if p.DefinitionRefs.Record.Name != "ceremony.json" || p.DefinitionRefs.Signature.Name != "ceremony.sig" { + return DefinitionProtocol{}, errors.New("unexpected authenticated definition reference names") + } + case "proof-tool-mpc-ceremony-definition-v1", "proof-tool-mpc-ceremony-definition-v2", "proof-tool-mpc-ceremony-definition-v3": + if p.StorageWorkflow != "storage-first-v1" || p.ReleaseVerification != "" { + return DefinitionProtocol{}, errors.New("inconsistent legacy protocol selector") + } + default: + return DefinitionProtocol{}, errors.New("unsupported authenticated definition schema") + } + d := p.Definition + if d.Schema != definitionInspectionSchema || !taggedHash(d.CeremonyID, "sha256:") || (d.Mode != "rehearsal" && d.Mode != "production") || len(d.Phase1Participants) == 0 || len(d.Phase2Participants) == 0 || d.Journey == nil { + return DefinitionProtocol{}, errors.New("incomplete authenticated definition projection") + } + if err := validateRef(d.R1CSRef); err != nil { + return DefinitionProtocol{}, err + } + if _, err := d.RequireJourney(); err != nil { + return DefinitionProtocol{}, err + } + return p, nil +} + +type CheckpointDiscoveryV4 struct { + Schema string `json:"schema"` + Depth string `json:"depth"` + Discovery struct { + CeremonyID string `json:"ceremony_id"` + Sequence uint64 `json:"sequence"` + PreviousCheckpoint *SignedArtifactRefs `json:"previous_checkpoint,omitempty"` + VerificationDependencies []ArtifactRef `json:"verification_dependencies"` + Enrollment *SignedArtifactRefs `json:"enrollment,omitempty"` + } `json:"discovery"` + CheckpointRefs SignedArtifactRefs `json:"checkpoint_refs"` + AncestryVerified bool `json:"ancestry_verified"` + ArtifactsVerified bool `json:"artifacts_verified"` + MathematicsReplayed bool `json:"mathematics_replayed"` + GlobalFreshnessVerified bool `json:"global_freshness_verified"` +} + +type ContributionScopeV4 struct { + CeremonyID string `json:"ceremony_id"` + Phase string `json:"phase"` + Index uint8 `json:"index"` + ParticipantID string `json:"participant_id"` + ParentHeadID string `json:"parent_head_id"` +} + +type DeliverySlotV4 struct { + Scope ContributionScopeV4 `json:"scope"` + Kind string `json:"kind"` + AttemptID string `json:"attempt_id"` + Status string `json:"status"` + ContributionResultID string `json:"contribution_result_id,omitempty"` +} + +type CheckpointProgressV4 struct { + Phase1 CheckpointPhaseState `json:"phase1"` + Phase1Closure *SignedArtifactRefs `json:"phase1_closure,omitempty"` + Phase1Beacon *SignedArtifactRefs `json:"phase1_beacon,omitempty"` + Phase1Seal *SignedArtifactRefs `json:"phase1_seal,omitempty"` + Phase2 *CheckpointPhaseState `json:"phase2,omitempty"` + Phase2Closure *SignedArtifactRefs `json:"phase2_closure,omitempty"` + Phase2Beacon *SignedArtifactRefs `json:"phase2_beacon,omitempty"` + FinalCandidate *SignedArtifactRefs `json:"final_candidate,omitempty"` + ReleaseReview *SignedArtifactRefs `json:"release_review,omitempty"` + FinalRelease *SignedArtifactRefs `json:"final_release,omitempty"` + Terminal *struct { + Kind string `json:"kind"` + Record SignedArtifactRefs `json:"record"` + RestartDefinition *SignedArtifactRefs `json:"restart_definition,omitempty"` + } `json:"terminal,omitempty"` +} + +// This projection is parsed only from successful approved-tool output, never +// directly from a downloaded checkpoint. Fields not needed by Relay are omitted. +type CheckpointStateV4 struct { + Schema string `json:"schema"` + Workflow string `json:"workflow"` + CeremonyID string `json:"ceremony_id"` + ReleaseVerification string `json:"release_verification"` + Sequence uint64 `json:"sequence"` + Definition SignedArtifactRefs `json:"definition"` + PreviousCheckpoint *SignedArtifactRefs `json:"previous_checkpoint,omitempty"` + Transition struct { + Kind string `json:"kind"` + } `json:"transition"` + Progress CheckpointProgressV4 `json:"progress"` + AcceptedArtifacts []ArtifactRef `json:"accepted_artifacts"` + Deliveries []DeliverySlotV4 `json:"deliveries"` +} + +type CheckpointInspectionV4 struct { + Schema string `json:"schema"` + Depth string `json:"depth"` + Checkpoint CheckpointStateV4 `json:"checkpoint"` + CheckpointRefs SignedArtifactRefs `json:"checkpoint_refs"` + Commitments CheckpointCommitmentsV4 `json:"commitments"` + ArtifactsVerified bool `json:"artifacts_verified"` + MathematicsReplayed bool `json:"mathematics_replayed"` + GlobalFreshnessVerified bool `json:"global_freshness_verified"` +} + +// RequiredPublicArtifactsV4 returns the exact public files a normal role needs +// in addition to checkpoint ancestry. The set is derived only from an +// approved-tool inspection: callers must never manufacture it by parsing a +// downloaded checkpoint themselves. +// +// Before release review, contribution-capable roles receive every accepted +// replay artifact: Phase 2 computation must replay the closed Phase 1 rather +// than trust a seal alone. After the coordinator has completed the mandatory +// full replay and frozen release review, non-replaying release roles may omit +// historical contribution payloads while still receiving the complete final +// release inventory. +func RequiredPublicArtifactsV4(p CheckpointInspectionV4) ([]ArtifactRef, error) { + if _, err := validateStoredInspectionV4(p); err != nil { + return nil, err + } + refs := map[string]ArtifactRef{} + add := func(ref ArtifactRef, limit int64) error { + if err := validateBoundedRefV4(ref, limit); err != nil { + return err + } + if previous, ok := refs[ref.Name]; ok && previous != ref { + return errors.New("authenticated public artifact name has conflicting references") + } + refs[ref.Name] = ref + return nil + } + addPair := func(pair *SignedArtifactRefs) error { + if pair == nil { + return nil + } + if err := add(pair.Record, 16<<20); err != nil { + return err + } + return add(pair.Signature, 4096) + } + if err := addPair(&p.Checkpoint.Definition); err != nil { + return nil, err + } + // The authenticated accepted-artifact inventory also contains transition + // evidence such as enrollment records and disclosures. Publish those bytes + // before the checkpoint that first references them. Until release review is + // frozen, retain historical replay payloads too: later computation and the + // coordinator's mandatory replay depend on them. + for _, ref := range p.Checkpoint.AcceptedArtifacts { + if p.Checkpoint.Progress.ReleaseReview != nil && historicalReplayPayloadV4(ref.Name) { + continue + } + if err := add(ref, 16<<30); err != nil { + return nil, err + } + } + if p.Checkpoint.Progress.ReleaseReview == nil { + phases := []*CheckpointPhaseState{&p.Checkpoint.Progress.Phase1, p.Checkpoint.Progress.Phase2} + for _, phase := range phases { + if phase == nil { + continue + } + if err := addPair(&phase.Chain); err != nil { + return nil, err + } + if err := add(phase.HeadPayload, 16<<30); err != nil { + return nil, err + } + } + } + for _, pair := range []*SignedArtifactRefs{ + p.Checkpoint.Progress.Phase1Closure, + p.Checkpoint.Progress.Phase1Beacon, + p.Checkpoint.Progress.Phase1Seal, + p.Checkpoint.Progress.Phase2Closure, + p.Checkpoint.Progress.Phase2Beacon, + p.Checkpoint.Progress.FinalCandidate, + p.Checkpoint.Progress.ReleaseReview, + p.Checkpoint.Progress.FinalRelease, + } { + if err := addPair(pair); err != nil { + return nil, err + } + } + for _, ref := range p.Commitments.FinalReleaseArtifacts { + if err := add(ref, 16<<30); err != nil { + return nil, err + } + } + if terminal := p.Checkpoint.Progress.Terminal; terminal != nil { + if err := addPair(&terminal.Record); err != nil { + return nil, err + } + if err := addPair(terminal.RestartDefinition); err != nil { + return nil, err + } + } + result := make([]ArtifactRef, 0, len(refs)) + for _, ref := range refs { + result = append(result, ref) + } + slices.SortFunc(result, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + return result, nil +} + +func historicalReplayPayloadV4(name string) bool { + if name == "phase1/genesis.bin" || name == "phase2/genesis.bin" { + return true + } + return (strings.HasPrefix(name, "phase1/contributions/") || strings.HasPrefix(name, "phase2/contributions/")) && strings.HasSuffix(name, "/contribution.bin") +} + +func (i Inspector) checkpointV4(action, root, record, signature string) (inspectionResult, error) { + return i.execute("checkpoint", action, "--ceremony", i.CeremonyPath, "--ceremony-signature", i.CeremonySignaturePath, "--coordinator-public-key-file", i.CoordinatorPublicKeyPath, "--artifact-root", root, "--checkpoint", record, "--checkpoint-signature", signature) +} + +func (i Inspector) DiscoverCheckpointV4(root, record, signature string) (CheckpointDiscoveryV4, error) { + r, err := i.checkpointV4("inspect-signed-v4", root, record, signature) + if err != nil { + return CheckpointDiscoveryV4{}, err + } + if r.Command != "checkpoint inspect-signed-v4" || r.CheckpointDiscoveryV4 == nil { + return CheckpointDiscoveryV4{}, errors.New("missing signed checkpoint discovery") + } + p := *r.CheckpointDiscoveryV4 + if p.Schema != "proof-tool-mpc-checkpoint-discovery-v4" || p.Depth != "signed-checkpoint-discovery" || p.AncestryVerified || p.ArtifactsVerified || p.MathematicsReplayed || p.GlobalFreshnessVerified { + return CheckpointDiscoveryV4{}, errors.New("invalid checkpoint discovery boundary") + } + if err := validateCheckpointMetadataV4(p.Discovery.CeremonyID, p.Discovery.Sequence, p.CheckpointRefs, p.Discovery.PreviousCheckpoint); err != nil { + return CheckpointDiscoveryV4{}, err + } + deps := p.Discovery.VerificationDependencies + if deps == nil || len(deps) > 5 { + return CheckpointDiscoveryV4{}, errors.New("invalid discovery dependencies") + } + for _, ref := range deps { + if err := validateBoundedRefV4(ref, 16<<20); err != nil { + return CheckpointDiscoveryV4{}, err + } + } + if p.Discovery.Enrollment != nil { + if err := validatePairV4(*p.Discovery.Enrollment); err != nil { + return CheckpointDiscoveryV4{}, err + } + } + return p, nil +} + +func (i Inspector) StoredCheckpointV4(root, record, signature string) (CheckpointInspectionV4, error) { + r, err := i.checkpointV4("verify-stored-v4", root, record, signature) + if err != nil { + return CheckpointInspectionV4{}, err + } + if r.Command != "checkpoint verify-stored-v4" || r.CheckpointInspectionV4 == nil { + return CheckpointInspectionV4{}, errors.New("missing verified checkpoint ancestry") + } + return validateStoredInspectionV4(*r.CheckpointInspectionV4) +} + +func validateStoredInspectionV4(p CheckpointInspectionV4) (CheckpointInspectionV4, error) { + c := p.Checkpoint + if p.Schema != "proof-tool-mpc-checkpoint-inspection-v4" || p.Depth != "checkpoint-structure" || p.ArtifactsVerified || p.MathematicsReplayed || p.GlobalFreshnessVerified || c.Schema != "proof-tool-mpc-checkpoint-v4" || c.Workflow != "storage-first-v2" || c.ReleaseVerification != "coordinator-full-replay-v1" { + return CheckpointInspectionV4{}, errors.New("invalid checkpoint verification boundary") + } + if err := validateCheckpointMetadataV4(c.CeremonyID, c.Sequence, p.CheckpointRefs, c.PreviousCheckpoint); err != nil { + return CheckpointInspectionV4{}, err + } + if err := validatePairV4(c.Definition); err != nil { + return CheckpointInspectionV4{}, err + } + if c.Deliveries == nil || len(c.Deliveries) > 4096 { + return CheckpointInspectionV4{}, errors.New("invalid delivery projection") + } + if err := validateProgressV4(c); err != nil { + return CheckpointInspectionV4{}, err + } + if err := validateCommitmentsV4(c, p.Commitments); err != nil { + return CheckpointInspectionV4{}, err + } + return p, nil +} + +// These are projection-shape checks, not a second implementation of legal +// ceremony transitions. Proof-tool remains the authority for that validation. +func validateProgressV4(c CheckpointStateV4) error { + phase := func(p CheckpointPhaseState, want string) error { + if p.Phase != want || p.AcceptedCount > 20 || !taggedHash(p.HeadRecordID, "sha256:") { + return errors.New("invalid phase progress projection") + } + if err := validatePairV4(p.Chain); err != nil { + return err + } + return validateBoundedRefV4(p.HeadPayload, 16<<30) + } + p := c.Progress + if c.AcceptedArtifacts == nil || len(c.AcceptedArtifacts) > 2048 { + return errors.New("invalid accepted artifact projection") + } + for index, ref := range c.AcceptedArtifacts { + if err := validateBoundedRefV4(ref, 16<<30); err != nil { + return err + } + if index > 0 && c.AcceptedArtifacts[index-1].Name >= ref.Name { + return errors.New("accepted artifact projection must be sorted and unique") + } + } + if err := phase(p.Phase1, "phase1"); err != nil { + return err + } + if p.Phase2 != nil { + if err := phase(*p.Phase2, "phase2"); err != nil { + return err + } + } + for _, pair := range []*SignedArtifactRefs{p.Phase1Closure, p.Phase1Beacon, p.Phase1Seal, p.Phase2Closure, p.Phase2Beacon, p.FinalCandidate, p.ReleaseReview, p.FinalRelease} { + if pair != nil { + if err := validatePairV4(*pair); err != nil { + return err + } + } + } + if terminal := p.Terminal; terminal != nil { + if (terminal.Kind != "abort" && terminal.Kind != "restart") || (terminal.Kind == "restart") != (terminal.RestartDefinition != nil) || p.FinalRelease != nil { + return errors.New("invalid terminal progress projection") + } + if err := validatePairV4(terminal.Record); err != nil { + return err + } + if terminal.RestartDefinition != nil { + if err := validatePairV4(*terminal.RestartDefinition); err != nil { + return err + } + } + } + seen := map[string]bool{} + for _, slot := range c.Deliveries { + s := slot.Scope + if s.CeremonyID != c.CeremonyID || (s.Phase != "phase1" && s.Phase != "phase2") || s.Index == 0 || s.Index > 20 || s.ParticipantID == "" || !taggedHash(s.ParentHeadID, "sha256:") { + return errors.New("invalid delivery scope projection") + } + if len(slot.AttemptID) != 32 || strings.ToLower(slot.AttemptID) != slot.AttemptID { + return errors.New("invalid delivery attempt projection") + } + if _, err := hex.DecodeString(slot.AttemptID); err != nil { + return err + } + if seen[slot.AttemptID] { + return errors.New("duplicate delivery attempt projection") + } + seen[slot.AttemptID] = true + if slot.Kind != "candidate" { + return errors.New("invalid delivery kind projection") + } + switch slot.Status { + case "allocated", "retired": + if slot.ContributionResultID != "" { + return errors.New("unaccepted delivery claims a candidate result") + } + case "accepted", "rejected": + if !taggedHash(slot.ContributionResultID, "sha256:") { + return errors.New("missing candidate disposition identity") + } + default: + return errors.New("invalid delivery status projection") + } + } + return nil +} + +func taggedHash(value, prefix string) bool { + if !strings.HasPrefix(value, prefix) || len(value) != len(prefix)+64 || strings.ToLower(value) != value { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(value, prefix)) + return err == nil +} + +func validateBoundedRefV4(ref ArtifactRef, limit int64) error { + if err := validateRef(ref); err != nil { + return err + } + if !taggedHash(ref.Digest.SHA256, "sha256:") || !taggedHash(ref.Digest.Blake2b256, "blake2b256:") || ref.Digest.Size <= 0 || ref.Digest.Size > limit { + return fmt.Errorf("invalid bounded artifact reference %q", ref.Name) + } + return nil +} + +func validatePairV4(pair SignedArtifactRefs) error { + if pair.Record.Name == pair.Signature.Name { + return errors.New("record and signature names coincide") + } + if err := validateBoundedRefV4(pair.Record, 16<<20); err != nil { + return err + } + return validateBoundedRefV4(pair.Signature, 4096) +} + +func validateCheckpointMetadataV4(id string, sequence uint64, pair SignedArtifactRefs, previous *SignedArtifactRefs) error { + if !taggedHash(id, "sha256:") || sequence > MaxCheckpointSequenceV4 || (sequence == 0) != (previous == nil) { + return errors.New("invalid checkpoint identity or ancestry metadata") + } + if err := validatePairV4(pair); err != nil { + return err + } + if previous != nil { + return validatePairV4(*previous) + } + return nil +} diff --git a/internal/transcript/inspect_v4_test.go b/internal/transcript/inspect_v4_test.go new file mode 100644 index 0000000..d7bc7e7 --- /dev/null +++ b/internal/transcript/inspect_v4_test.go @@ -0,0 +1,279 @@ +package transcript + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "testing" +) + +func TestDefinitionProtocolV4ExactDispatch(t *testing.T) { + for version := 1; version <= 4; version++ { + p := DefinitionProtocol{Schema: "proof-tool-mpc-definition-protocol-inspection-v1", DefinitionSchema: fmt.Sprintf("proof-tool-mpc-ceremony-definition-v%d", version), StorageWorkflow: "storage-first-v1", Definition: testDefinition()} + p.Definition.CeremonyID = "sha256:" + hex64 + p.Definition.Mode = "rehearsal" + p.Definition.Journey = &DefinitionJourney{Schema: "proof-tool-mpc-definition-journey-v2", ObserverRequirementSource: "signed ceremony assurance_policy"} + for _, role := range []string{"coordinator", "release-signer", "participant"} { + p.Definition.Journey.RequiredEnrollments = append(p.Definition.Journey.RequiredEnrollments, ExpectedEnrollment{Role: role, RoleIndex: 1, Identity: PublicIdentity{ID: role, KeyID: "key-" + role, PublicKeyFingerprint: "fingerprint-" + role}}) + } + if version == 4 { + p.StorageWorkflow, p.ReleaseVerification = "storage-first-v2", "coordinator-full-replay-v1" + p.DefinitionRefs = SignedArtifactRefs{Record: inspectionTestRef("ceremony.json"), Signature: inspectionTestRef("ceremony.sig")} + } + check := func(p DefinitionProtocol) (DefinitionProtocol, error) { + i := testInspector() + i.run = inspectionTestRunner(t, inspectionResult{Schema: commandResultSchema, OK: true, Command: "inspect definition-protocol", DefinitionProtocolInspection: &p}, "inspect definition-protocol") + return i.DefinitionProtocol() + } + got, err := check(p) + if err != nil || !reflect.DeepEqual(got, p) || got.UsesV4() != (version == 4) { + t.Fatalf("version %d: %+v %v", version, got, err) + } + if version == 4 { + for _, refs := range []SignedArtifactRefs{{}, {Record: inspectionTestRef("other.json"), Signature: p.DefinitionRefs.Signature}} { + bad := p + bad.DefinitionRefs = refs + if _, err := check(bad); err == nil { + t.Fatal("invalid definition binding accepted") + } + } + } + bad := p + bad.ReleaseVerification = "skip-replay" + if _, err := check(bad); err == nil { + t.Fatal("unsupported policy accepted") + } + bad = p + bad.StorageWorkflow = "storage-first-v99" + if _, err := check(bad); err == nil { + t.Fatal("unsupported workflow accepted") + } + } + i := testInspector() + calls := 0 + i.run = func(string, ...string) ([]byte, []byte, error) { + calls++ + return nil, nil, errors.New("unsupported command") + } + if _, err := i.DefinitionProtocol(); err == nil || calls != 1 { + t.Fatal("inspection failure triggered fallback") + } +} + +func TestStoredCheckpointV4RejectsMalformedProgress(t *testing.T) { + pair := SignedArtifactRefs{Record: inspectionTestRef("record.json"), Signature: inspectionTestRef("record.sig")} + c := CheckpointStateV4{Schema: "proof-tool-mpc-checkpoint-v4", Workflow: "storage-first-v2", ReleaseVerification: "coordinator-full-replay-v1", CeremonyID: "sha256:" + hex64, Definition: pair, AcceptedArtifacts: []ArtifactRef{}, Deliveries: []DeliverySlotV4{{Scope: ContributionScopeV4{CeremonyID: "sha256:" + hex64, Phase: "phase1", Index: 1, ParticipantID: "participant", ParentHeadID: "sha256:" + hex64}, Kind: "candidate", Status: "allocated", AttemptID: strings.Repeat("ab", 16)}}} + c.Progress.Phase1 = CheckpointPhaseState{Phase: "phase1", HeadRecordID: "sha256:" + hex64, HeadPayload: inspectionTestRef("payload.bin"), Chain: pair} + check := func(c CheckpointStateV4) error { + i := testInspector() + p := CheckpointInspectionV4{Schema: "proof-tool-mpc-checkpoint-inspection-v4", Depth: "checkpoint-structure", Checkpoint: c, CheckpointRefs: pair, Commitments: CheckpointCommitmentsV4{Enrollments: []SignedArtifactRefs{}, Turns: []TurnCommitmentV4{}}} + i.run = inspectionTestRunner(t, inspectionResult{Schema: commandResultSchema, OK: true, Command: "checkpoint verify-stored-v4", CheckpointInspectionV4: &p}, "checkpoint verify-stored-v4") + _, err := i.StoredCheckpointV4("/stage", "/stage/record.json", "/stage/record.sig") + return err + } + if err := check(c); err != nil { + t.Fatal(err) + } + for name, change := range map[string]func(*CheckpointStateV4){ + "phase": func(c *CheckpointStateV4) { c.Progress.Phase1.Phase = "phase2" }, + "count": func(c *CheckpointStateV4) { c.Progress.Phase1.AcceptedCount = 21 }, + "head": func(c *CheckpointStateV4) { c.Progress.Phase1.HeadRecordID = "missing" }, + "pair": func(c *CheckpointStateV4) { c.Progress.Phase1.Chain.Signature.Digest.Size = 4097 }, + "optional pair": func(c *CheckpointStateV4) { c.Progress.FinalRelease = &SignedArtifactRefs{} }, + "scope": func(c *CheckpointStateV4) { c.Deliveries[0].Scope.CeremonyID = "other" }, + "turn": func(c *CheckpointStateV4) { c.Deliveries[0].Scope.Index = 0 }, + "kind": func(c *CheckpointStateV4) { c.Deliveries[0].Kind = "anything" }, + "status": func(c *CheckpointStateV4) { c.Deliveries[0].Status = "done" }, + "attempt": func(c *CheckpointStateV4) { c.Deliveries[0].AttemptID = "z" }, + "duplicate": func(c *CheckpointStateV4) { c.Deliveries = append(c.Deliveries, c.Deliveries[0]) }, + "result": func(c *CheckpointStateV4) { c.Deliveries[0].Status = "accepted" }, + } { + raw, _ := json.Marshal(c) + var bad CheckpointStateV4 + if err := json.Unmarshal(raw, &bad); err != nil { + t.Fatal(err) + } + change(&bad) + if err := check(bad); err == nil { + t.Errorf("accepted malformed %s", name) + } + } +} + +func TestRequiredPublicArtifactsV4ReleaseReviewOmitsHistoricalReplayPayloads(t *testing.T) { + pair := func(base string) SignedArtifactRefs { + return SignedArtifactRefs{Record: inspectionTestRef(base + ".json"), Signature: inspectionTestRef(base + ".sig")} + } + definition := pair("ceremony") + chain := pair("phase1/chain-0001") + review := pair("operational/evidence-bundle") + checkpoint := pair("checkpoints/0012/checkpoint") + contribution := inspectionTestRef("phase1/contributions/0001/contribution.bin") + genesis := inspectionTestRef("phase1/genesis.bin") + key := inspectionTestRef("final/candidate/ownership.pk") + c := CheckpointStateV4{Schema: "proof-tool-mpc-checkpoint-v4", Workflow: "storage-first-v2", ReleaseVerification: "coordinator-full-replay-v1", CeremonyID: "sha256:" + hex64, Definition: definition, AcceptedArtifacts: []ArtifactRef{key, review.Record, review.Signature, contribution, genesis}, Deliveries: []DeliverySlotV4{}} + c.Progress.Phase1 = CheckpointPhaseState{Phase: "phase1", HeadRecordID: "sha256:" + hex64, HeadPayload: contribution, Chain: chain} + c.Progress.ReleaseReview = &review + p := CheckpointInspectionV4{Schema: "proof-tool-mpc-checkpoint-inspection-v4", Depth: "checkpoint-structure", Checkpoint: c, CheckpointRefs: checkpoint, Commitments: CheckpointCommitmentsV4{Enrollments: []SignedArtifactRefs{}, Turns: []TurnCommitmentV4{}}} + refs, err := RequiredPublicArtifactsV4(p) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, ref := range refs { + names[ref.Name] = true + } + for _, want := range []string{"ceremony.json", "operational/evidence-bundle.json", "operational/evidence-bundle.sig", "final/candidate/ownership.pk"} { + if !names[want] { + t.Fatalf("missing release dependency %s", want) + } + } + for _, unwanted := range []string{contribution.Name, genesis.Name} { + if names[unwanted] { + t.Fatalf("historical replay payload scheduled for release-signer download: %s", unwanted) + } + } +} + +func TestRequiredPublicArtifactsV4PublishesEnrollmentEvidenceBeforeReview(t *testing.T) { + pair := func(base string) SignedArtifactRefs { + return SignedArtifactRefs{Record: inspectionTestRef(base + ".json"), Signature: inspectionTestRef(base + ".sig")} + } + definition := pair("ceremony") + chain := pair("phase1/chain-0000") + checkpoint := pair("checkpoints/enrollment/checkpoint") + enrollment := pair("enrollments/participant-01/enrollment") + disclosure := inspectionTestRef("enrollments/participant-01/disclosure.txt") + genesis := inspectionTestRef("phase1/genesis.bin") + c := CheckpointStateV4{Schema: "proof-tool-mpc-checkpoint-v4", Workflow: "storage-first-v2", ReleaseVerification: "coordinator-full-replay-v1", CeremonyID: "sha256:" + hex64, Definition: definition, AcceptedArtifacts: []ArtifactRef{disclosure, enrollment.Record, enrollment.Signature, genesis}, Deliveries: []DeliverySlotV4{}} + c.Progress.Phase1 = CheckpointPhaseState{Phase: "phase1", HeadRecordID: "sha256:" + hex64, HeadPayload: genesis, Chain: chain} + p := CheckpointInspectionV4{Schema: "proof-tool-mpc-checkpoint-inspection-v4", Depth: "checkpoint-structure", Checkpoint: c, CheckpointRefs: checkpoint, Commitments: CheckpointCommitmentsV4{Enrollments: []SignedArtifactRefs{enrollment}, Turns: []TurnCommitmentV4{}}} + refs, err := RequiredPublicArtifactsV4(p) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, ref := range refs { + names[ref.Name] = true + } + for _, want := range []string{enrollment.Record.Name, enrollment.Signature.Name, disclosure.Name, genesis.Name} { + if !names[want] { + t.Fatalf("missing pre-review public dependency %s", want) + } + } +} + +func TestRequiredPublicArtifactsV4IncludesPhase1ReplayForPhase2(t *testing.T) { + pair := func(base string) SignedArtifactRefs { + return SignedArtifactRefs{Record: inspectionTestRef(base + ".json"), Signature: inspectionTestRef(base + ".sig")} + } + definition := pair("ceremony") + phase1Chain := pair("phase1/chain-0001") + phase2Chain := pair("phase2/chain-0000") + checkpoint := pair("checkpoints/phase2/allocate/checkpoint") + phase1Genesis := inspectionTestRef("phase1/genesis.bin") + phase1Contribution := inspectionTestRef("phase1/contributions/0001/contribution.bin") + phase2Genesis := inspectionTestRef("phase2/genesis.bin") + c := CheckpointStateV4{Schema: "proof-tool-mpc-checkpoint-v4", Workflow: "storage-first-v2", ReleaseVerification: "coordinator-full-replay-v1", CeremonyID: "sha256:" + hex64, Definition: definition, AcceptedArtifacts: []ArtifactRef{phase1Contribution, phase1Genesis, phase2Genesis}, Deliveries: []DeliverySlotV4{}} + c.Progress.Phase1 = CheckpointPhaseState{Phase: "phase1", HeadRecordID: "sha256:" + hex64, HeadPayload: phase1Contribution, Chain: phase1Chain} + c.Progress.Phase2 = &CheckpointPhaseState{Phase: "phase2", HeadRecordID: "sha256:" + hex64, HeadPayload: phase2Genesis, Chain: phase2Chain} + p := CheckpointInspectionV4{Schema: "proof-tool-mpc-checkpoint-inspection-v4", Depth: "checkpoint-structure", Checkpoint: c, CheckpointRefs: checkpoint, Commitments: CheckpointCommitmentsV4{Enrollments: []SignedArtifactRefs{}, Turns: []TurnCommitmentV4{}}} + refs, err := RequiredPublicArtifactsV4(p) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, ref := range refs { + names[ref.Name] = true + } + for _, want := range []string{phase1Genesis.Name, phase1Contribution.Name, phase2Genesis.Name} { + if !names[want] { + t.Fatalf("missing Phase 2 replay dependency %s", want) + } + } +} + +func TestRequiredPublicArtifactsV4IncludesClosedFinalReleaseInventory(t *testing.T) { + pair := func(base string) SignedArtifactRefs { + return SignedArtifactRefs{Record: inspectionTestRef(base + ".json"), Signature: inspectionTestRef(base + ".sig")} + } + definition := pair("ceremony") + chain := pair("phase1/chain-0001") + finalRelease := pair("final/release/release") + checkpoint := pair("checkpoints/0013/checkpoint") + bundle := inspectionTestRef("final/release/key-bundle.json") + 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: chain} + c.Progress.FinalRelease = &finalRelease + p := CheckpointInspectionV4{Schema: "proof-tool-mpc-checkpoint-inspection-v4", Depth: "checkpoint-structure", Checkpoint: c, CheckpointRefs: checkpoint, Commitments: CheckpointCommitmentsV4{Enrollments: []SignedArtifactRefs{}, Turns: []TurnCommitmentV4{}, FinalReleaseArtifacts: []ArtifactRef{bundle, finalRelease.Record, finalRelease.Signature}}} + refs, err := RequiredPublicArtifactsV4(p) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, ref := range refs { + names[ref.Name] = true + } + for _, want := range []string{bundle.Name, finalRelease.Record.Name, finalRelease.Signature.Name} { + if !names[want] { + t.Fatalf("missing final release member %s", want) + } + } + bad := p + bad.Commitments.FinalReleaseArtifacts = []ArtifactRef{inspectionTestRef("outside-release.json")} + if _, err := RequiredPublicArtifactsV4(bad); err == nil { + t.Fatal("accepted final release inventory outside final/release") + } +} + +func TestCheckpointV4DiscoveryProjectionBoundary(t *testing.T) { + p := CheckpointDiscoveryV4{Schema: "proof-tool-mpc-checkpoint-discovery-v4", Depth: "signed-checkpoint-discovery", CheckpointRefs: SignedArtifactRefs{Record: inspectionTestRef("checkpoint.json"), Signature: inspectionTestRef("checkpoint.sig")}} + p.Discovery.CeremonyID = "sha256:" + hex64 + p.Discovery.VerificationDependencies = []ArtifactRef{} + check := func(p CheckpointDiscoveryV4) (CheckpointDiscoveryV4, error) { + i := testInspector() + i.run = inspectionTestRunner(t, inspectionResult{Schema: commandResultSchema, OK: true, Command: "checkpoint inspect-signed-v4", CheckpointDiscoveryV4: &p}, "checkpoint inspect-signed-v4") + return i.DiscoverCheckpointV4("/stage", "/stage/checkpoint.json", "/stage/checkpoint.sig") + } + if _, err := check(p); err != nil { + t.Fatal(err) + } + for name, change := range map[string]func(*CheckpointDiscoveryV4){ + "ancestry claim": func(p *CheckpointDiscoveryV4) { p.AncestryVerified = true }, + "artifact claim": func(p *CheckpointDiscoveryV4) { p.ArtifactsVerified = true }, + "replay claim": func(p *CheckpointDiscoveryV4) { p.MathematicsReplayed = true }, + "freshness claim": func(p *CheckpointDiscoveryV4) { p.GlobalFreshnessVerified = true }, + "wrong depth": func(p *CheckpointDiscoveryV4) { p.Depth = "checkpoint-structure" }, + "missing ancestor": func(p *CheckpointDiscoveryV4) { p.Discovery.Sequence = 1 }, + "oversize record": func(p *CheckpointDiscoveryV4) { p.CheckpointRefs.Record.Digest.Size = 16<<20 + 1 }, + "oversize signature": func(p *CheckpointDiscoveryV4) { p.CheckpointRefs.Signature.Digest.Size = 4097 }, + "same path": func(p *CheckpointDiscoveryV4) { p.CheckpointRefs.Signature.Name = p.CheckpointRefs.Record.Name }, + "bad path": func(p *CheckpointDiscoveryV4) { p.CheckpointRefs.Record.Name = "../escape" }, + "missing dependencies": func(p *CheckpointDiscoveryV4) { p.Discovery.VerificationDependencies = nil }, + "excess dependencies": func(p *CheckpointDiscoveryV4) { + for range 6 { + p.Discovery.VerificationDependencies = append(p.Discovery.VerificationDependencies, inspectionTestRef("record")) + } + }, + } { + bad := p + change(&bad) + if _, err := check(bad); err == nil { + t.Errorf("accepted %s", name) + } + } + previous := SignedArtifactRefs{Record: inspectionTestRef("previous.json"), Signature: inspectionTestRef("previous.sig")} + p.Discovery.PreviousCheckpoint = &previous + for _, sequence := range []uint64{1025, MaxCheckpointSequenceV4} { + p.Discovery.Sequence = sequence + if _, err := check(p); err != nil { + t.Fatalf("valid sequence %d rejected: %v", sequence, err) + } + } + p.Discovery.Sequence++ + if _, err := check(p); err == nil { + t.Fatal("protocol sequence overflow accepted") + } +} diff --git a/internal/transcript/journey.go b/internal/transcript/journey.go index a90d57a..8c259c1 100644 --- a/internal/transcript/journey.go +++ b/internal/transcript/journey.go @@ -15,6 +15,9 @@ type DefinitionJourney struct { RequiredEnrollments []ExpectedEnrollment `json:"required_enrollments"` MinimumPublicWitnesses int `json:"minimum_public_witnesses"` MinimumMirrorsPerAcceptedHead int `json:"minimum_mirrors_per_accepted_head"` + MinimumPassingCeremonyAudits int `json:"minimum_passing_ceremony_audits"` + MinimumExternalAuditSignoffs int `json:"minimum_external_audit_signoffs"` + BeaconRoundLeadSeconds uint32 `json:"beacon_round_lead_seconds"` ObserverRequirementSource string `json:"observer_requirement_source"` } type PhaseJourney struct { @@ -41,13 +44,22 @@ type Journey struct { } func (d Definition) RequireJourney() (DefinitionJourney, error) { - if d.Journey == nil || d.Journey.Schema != "proof-tool-mpc-definition-journey-v1" { + if d.Journey == nil || (d.Journey.Schema != "proof-tool-mpc-definition-journey-v1" && d.Journey.Schema != "proof-tool-mpc-definition-journey-v2") { return DefinitionJourney{}, errors.New("approved proof-tool does not provide authenticated journey requirements; use a matching new release") } j := *d.Journey - if j.MinimumPublicWitnesses < 1 || j.MinimumMirrorsPerAcceptedHead < 1 || j.ObserverRequirementSource == "" { + legacy := j.Schema == "proof-tool-mpc-definition-journey-v1" + if j.ObserverRequirementSource == "" { return j, errors.New("incomplete observer requirements from proof-tool") } + if legacy && (j.MinimumPublicWitnesses < 1 || j.MinimumMirrorsPerAcceptedHead < 1) { + return j, errors.New("incomplete observer requirements from legacy proof-tool") + } + for _, value := range []int{j.MinimumPublicWitnesses, j.MinimumMirrorsPerAcceptedHead, j.MinimumPassingCeremonyAudits, j.MinimumExternalAuditSignoffs} { + if value < 0 || value > 20 { + return j, errors.New("authenticated assurance requirement is outside supported bounds") + } + } roles := map[string]int{} ids := map[string]bool{} for _, e := range j.RequiredEnrollments { @@ -57,7 +69,11 @@ func (d Definition) RequireJourney() (DefinitionJourney, error) { } ids[e.Identity.ID] = true } - if roles["coordinator"] != 1 || roles["release-signer"] != 1 || roles["auditor"] < 1 || roles["participant"] < 1 || len(roles) != 4 { + if roles["coordinator"] != 1 || roles["release-signer"] != 1 || roles["participant"] < 1 || + (legacy && roles["auditor"] < 1) || + roles["auditor"] < j.MinimumPassingCeremonyAudits || + (!legacy && j.MinimumPassingCeremonyAudits == 0 && roles["auditor"] != 0) || + (len(roles) != 3 && len(roles) != 4) { return j, errors.New("required ceremony roster is incomplete") } return j, nil diff --git a/internal/transcript/journey_test.go b/internal/transcript/journey_test.go index 1302ed5..8e0476f 100644 --- a/internal/transcript/journey_test.go +++ b/internal/transcript/journey_test.go @@ -42,6 +42,24 @@ func TestDefinitionJourneyUsesAuthenticatedMinimums(t *testing.T) { } } +func TestDefinitionJourneyAllowsExplicitlyDisabledV2Assurance(t *testing.T) { + j := &DefinitionJourney{ + Schema: "proof-tool-mpc-definition-journey-v2", ObserverRequirementSource: "signed ceremony assurance_policy", + RequiredEnrollments: []ExpectedEnrollment{ + {Role: "coordinator", RoleIndex: 1, Identity: PublicIdentity{ID: "coordinator", KeyID: "coordinator-key", PublicKeyFingerprint: "coordinator-fingerprint"}}, + {Role: "release-signer", RoleIndex: 1, Identity: PublicIdentity{ID: "release", KeyID: "release-key", PublicKeyFingerprint: "release-fingerprint"}}, + {Role: "participant", RoleIndex: 1, Identity: PublicIdentity{ID: "participant", KeyID: "participant-key", PublicKeyFingerprint: "participant-fingerprint"}}, + }, + } + if _, err := (Definition{Journey: j}).RequireJourney(); err != nil { + t.Fatalf("zero-assurance journey rejected: %v", err) + } + j.MinimumPassingCeremonyAudits = 1 + if _, err := (Definition{Journey: j}).RequireJourney(); err == nil { + t.Fatal("positive audit requirement accepted without an auditor enrollment") + } +} + func TestJourneyRejectsIncompleteOrInconsistentMetadata(t *testing.T) { valid := func() Journey { return Journey{Schema: "proof-tool-mpc-journey-inspection-v1", CeremonyID: "ceremony", Mode: "rehearsal", Depth: "metadata", Phases: []PhaseJourney{ diff --git a/internal/transcript/submission.go b/internal/transcript/submission.go new file mode 100644 index 0000000..a38fe3f --- /dev/null +++ b/internal/transcript/submission.go @@ -0,0 +1,73 @@ +package transcript + +import ( + "errors" + "fmt" +) + +const ( + maxSubmissionRecordBytes = 16 << 20 + maxSubmissionSignatureBytes = 4096 +) + +// Submission asks the approved proof-tool to authenticate an exact inbox +// submission against an exact signed checkpoint slot. The proof-tool performs +// bounded reads of the manifest, envelope, and signature. +func (i Inspector) Submission(paths SubmissionInspectionPaths) (SubmissionInspection, error) { + result, err := i.execute( + "inspect", "submission", + "--ceremony", i.CeremonyPath, + "--ceremony-signature", i.CeremonySignaturePath, + "--coordinator-public-key-file", i.CoordinatorPublicKeyPath, + "--checkpoint", paths.CheckpointPath, + "--checkpoint-signature", paths.CheckpointSignaturePath, + "--kind", paths.Kind, + "--phase", paths.Phase, + "--index", fmt.Sprint(paths.Index), + "--submitter-id", paths.SubmitterID, + "--attempt-id", paths.AttemptID, + "--envelope", paths.EnvelopePath, + "--envelope-signature", paths.EnvelopeSignaturePath, + "--manifest", paths.ManifestPath, + ) + if err != nil { + return SubmissionInspection{}, err + } + if result.Command != "inspect submission" || result.SubmissionInspection == nil { + return SubmissionInspection{}, errors.New("mpc-ceremony returned no submission inspection") + } + inspection := *result.SubmissionInspection + if inspection.Schema != submissionInspectionSchema || inspection.CeremonyID == "" || + inspection.Workflow != "storage-first-v1" || inspection.RelayReleaseID == "" || + inspection.SubmitterID == "" || inspection.SubmitterKeyID == "" || inspection.SubmitterRole != "participant" || + (inspection.Kind != "receipt" && inspection.Kind != "candidate") || inspection.Phase != "phase1" || + inspection.Index == 0 || inspection.AttemptID == "" || inspection.ManifestKey == "" || + inspection.ParentCheckpointSHA256 == "" || inspection.AllocationCheckpointSHA256 == "" || inspection.ParentHeadID == "" { + return SubmissionInspection{}, errors.New("mpc-ceremony returned an invalid submission inspection") + } + if len(inspection.Payloads) == 0 || len(inspection.Payloads) > 64 { + return SubmissionInspection{}, errors.New("mpc-ceremony returned an invalid submission payload inventory") + } + for index, ref := range inspection.Payloads { + if err := validateRef(ref); err != nil { + return SubmissionInspection{}, fmt.Errorf("submission payload %d: %w", index, err) + } + } + manifest := ArtifactRef{Name: inspection.ManifestKey, Digest: inspection.ManifestDigest} + if err := validateRef(manifest); err != nil || manifest.Digest.Size <= 0 || manifest.Digest.Size > maxSubmissionRecordBytes { + return SubmissionInspection{}, errors.New("mpc-ceremony returned an invalid submission manifest digest") + } + for label, bounded := range map[string]struct { + digest Digest + max int64 + }{ + "envelope": {inspection.EnvelopeDigest, maxSubmissionRecordBytes}, + "envelope signature": {inspection.EnvelopeSignatureDigest, maxSubmissionSignatureBytes}, + } { + if err := validateRef(ArtifactRef{Name: "submission/" + label, Digest: bounded.digest}); err != nil || + bounded.digest.Size <= 0 || bounded.digest.Size > bounded.max { + return SubmissionInspection{}, fmt.Errorf("mpc-ceremony returned an invalid %s digest", label) + } + } + return inspection, nil +} diff --git a/internal/transcript/transcript.go b/internal/transcript/transcript.go index 330bfb7..9bf8640 100644 --- a/internal/transcript/transcript.go +++ b/internal/transcript/transcript.go @@ -12,6 +12,8 @@ import ( "path" "path/filepath" "strings" + + "golang.org/x/crypto/blake2b" ) // Digest is the transport view emitted by mpc-ceremony inspect. @@ -104,17 +106,29 @@ func Resolve(root, name string) (string, error) { // DigestFile returns the tagged SHA-256 and byte length of a local file. func DigestFile(path string) (string, int64, error) { + sha, _, size, err := DigestFileBoth(path) + return sha, size, err +} + +// DigestFileBoth returns the tagged SHA-256 and BLAKE2b-256 digests and byte +// length of a local file. V4 retained-operation journals use both digest +// domains, matching authenticated protocol artifact references. +func DigestFileBoth(path string) (string, string, int64, error) { file, err := os.Open(path) if err != nil { - return "", 0, err + return "", "", 0, err } defer file.Close() - hash := sha256.New() - size, err := io.Copy(hash, file) + shaHash := sha256.New() + blakeHash, err := blake2b.New256(nil) + if err != nil { + return "", "", 0, err + } + size, err := io.Copy(io.MultiWriter(shaHash, blakeHash), file) if err != nil { - return "", 0, err + return "", "", 0, err } - return "sha256:" + hex.EncodeToString(hash.Sum(nil)), size, nil + return "sha256:" + hex.EncodeToString(shaHash.Sum(nil)), "blake2b256:" + hex.EncodeToString(blakeHash.Sum(nil)), size, nil } // AcceptedCount returns how many contributions the chain has accepted. This is diff --git a/models/storagefirst/DeliveryRetries.tla b/models/storagefirst/DeliveryRetries.tla new file mode 100644 index 0000000..ef834a0 --- /dev/null +++ b/models/storagefirst/DeliveryRetries.tla @@ -0,0 +1,63 @@ +------------------------ MODULE DeliveryRetries ------------------------ +EXTENDS Integers, Sequences, FiniteSets, TLC + +CONSTANTS MaxAttempts, Results, ForceReplacement +VARIABLES deliveries, uploaded, rejected, accepted +vars == <> + +Active == {i \in 1..Len(deliveries) : deliveries[i] = "allocated"} +Init == + /\ deliveries = <<>> + /\ uploaded = [i \in 1..MaxAttempts |-> "none"] + /\ rejected = {} + /\ accepted = "none" + +Allocate == + /\ Active = {} + /\ accepted = "none" + /\ Len(deliveries) < MaxAttempts + /\ deliveries' = Append(deliveries, "allocated") + /\ UNCHANGED <> + +Deliver(i, result) == + /\ i \in Active + /\ uploaded[i] = "none" + /\ result \in Results + /\ uploaded' = [uploaded EXCEPT ![i] = result] + /\ UNCHANGED <> + +Retire(i) == + /\ i \in Active + \* The reviewed bug required replacement even at the history limit. + /\ ~ForceReplacement \/ Len(deliveries) < MaxAttempts + /\ deliveries' = [deliveries EXCEPT ![i] = "retired"] + /\ UNCHANGED <> + +Reject(i) == + /\ i \in Active + /\ uploaded[i] \in Results + /\ ~ForceReplacement \/ Len(deliveries) < MaxAttempts + /\ deliveries' = [deliveries EXCEPT ![i] = "rejected"] + /\ rejected' = rejected \cup {uploaded[i]} + /\ UNCHANGED <> + +Accept(i) == + /\ i \in Active + /\ uploaded[i] \in Results \ rejected + /\ deliveries' = [deliveries EXCEPT ![i] = "accepted"] + /\ accepted' = uploaded[i] + /\ UNCHANGED <> + +Restart == UNCHANGED vars +Next == Allocate \/ Restart \/ + (\E i \in 1..MaxAttempts : Retire(i) \/ Reject(i) \/ Accept(i) \/ + (\E result \in Results : Deliver(i, result))) +Spec == Init /\ [][Next]_vars + +BoundedHistory == Len(deliveries) <= MaxAttempts +OneActive == Cardinality(Active) <= 1 +RejectedCannotBeAccepted == accepted \notin rejected +AcceptanceIsTerminal == accepted # "none" => Active = {} +ActiveCanRetire == Active # {} => (\E i \in Active : ENABLED Retire(i)) +NeverAccepted == accepted = "none" +======================================================================= diff --git a/models/storagefirst/Phase1Turn.tla b/models/storagefirst/Phase1Turn.tla new file mode 100644 index 0000000..172b102 --- /dev/null +++ b/models/storagefirst/Phase1Turn.tla @@ -0,0 +1,101 @@ +-------------------------- MODULE Phase1Turn -------------------------- +EXTENDS Integers, Sequences, TLC + +CONSTANT Participant + +VARIABLES root, objects, coordinatorJournal, participantLocal + +vars == <> + +Stages == { + "initial", "outbound-open", "receipt-accepted", "candidate-accepted" +} + +Attempts == {"none", "receipt-1", "candidate-1"} + +Checkpoint(sequence, parent, stage, receiptAttempt, candidateAttempt) == + [sequence |-> sequence, + parent |-> parent, + stage |-> stage, + participant |-> Participant, + receiptAttempt |-> receiptAttempt, + candidateAttempt |-> candidateAttempt] + +InitialCheckpoint == Checkpoint(0, "none", "initial", "none", "none") + +Init == + /\ root = InitialCheckpoint + /\ objects = {InitialCheckpoint} + /\ coordinatorJournal = "idle" + /\ participantLocal = [receiptUploaded |-> FALSE, + candidateComputed |-> FALSE, + candidateUploaded |-> FALSE] + +OpenOutbound == + LET next == Checkpoint(1, root, "outbound-open", "receipt-1", "none") IN + /\ root.stage = "initial" + /\ coordinatorJournal = "idle" + /\ coordinatorJournal' = "outbound-prepared" + /\ objects' = objects \cup {next} + /\ root' = next + /\ UNCHANGED participantLocal + +UploadReceipt == + /\ root.stage = "outbound-open" + /\ ~participantLocal.receiptUploaded + /\ participantLocal' = [participantLocal EXCEPT !.receiptUploaded = TRUE] + /\ UNCHANGED <> + +AcceptReceipt == + LET next == Checkpoint(2, root, "receipt-accepted", "receipt-1", "candidate-1") IN + /\ root.stage = "outbound-open" + /\ participantLocal.receiptUploaded + /\ objects' = objects \cup {next} + /\ root' = next + /\ coordinatorJournal' = "receipt-accepted" + /\ UNCHANGED participantLocal + +ComputeCandidate == + /\ root.stage = "receipt-accepted" + /\ ~participantLocal.candidateComputed + /\ participantLocal' = [participantLocal EXCEPT !.candidateComputed = TRUE] + /\ UNCHANGED <> + +UploadCandidate == + /\ root.stage = "receipt-accepted" + /\ participantLocal.candidateComputed + /\ ~participantLocal.candidateUploaded + /\ participantLocal' = [participantLocal EXCEPT !.candidateUploaded = TRUE] + /\ UNCHANGED <> + +AcceptCandidate == + LET next == Checkpoint(3, root, "candidate-accepted", "receipt-1", "candidate-1") IN + /\ root.stage = "receipt-accepted" + /\ participantLocal.candidateUploaded + /\ objects' = objects \cup {next} + /\ root' = next + /\ coordinatorJournal' = "candidate-accepted" + /\ UNCHANGED participantLocal + +Restart == UNCHANGED vars + +Next == OpenOutbound \/ UploadReceipt \/ AcceptReceipt \/ + ComputeCandidate \/ UploadCandidate \/ AcceptCandidate \/ Restart + +Spec == Init /\ [][Next]_vars + +RootNamesPublishedObject == root \in objects +RootSequenceMatchesStage == + CASE root.stage = "initial" -> root.sequence = 0 + [] root.stage = "outbound-open" -> root.sequence = 1 + [] root.stage = "receipt-accepted" -> root.sequence = 2 + [] root.stage = "candidate-accepted" -> root.sequence = 3 + [] OTHER -> FALSE +CandidateRequiresAcceptedReceipt == + participantLocal.candidateComputed => root.stage \in {"receipt-accepted", "candidate-accepted"} +AcceptanceRequiresUpload == + root.stage = "candidate-accepted" => participantLocal.candidateUploaded +AttemptIsPreallocated == + /\ (participantLocal.receiptUploaded => root.receiptAttempt = "receipt-1") + /\ (participantLocal.candidateComputed => root.candidateAttempt = "candidate-1") +======================================================================= diff --git a/models/storagefirst/README.md b/models/storagefirst/README.md new file mode 100644 index 0000000..d3a87d4 --- /dev/null +++ b/models/storagefirst/README.md @@ -0,0 +1,55 @@ +# Storage-first ceremony model + +`Phase1Turn.tla` models the first implementation slice: one participant's +Phase 1 turn. It deliberately separates public checkpoint state from private +local state. A restart changes neither. + +The model checks that: + +- the mutable root names an immutable checkpoint that already exists; +- each public stage has one sequence number and advances in order; +- a participant cannot compute before its receipt is accepted; +- a coordinator cannot record acceptance before upload; and +- receipt and candidate attempt names are allocated before the role uses them. + +Run it with a local TLA+ installation: + +```sh +java -cp /path/to/tla2tools.jar tlc2.TLC -config phase1.cfg Phase1Turn.tla +``` + +This model is a protocol check, not an implementation test. Relay tests must +also execute the same boundaries against its real next-action evaluator and +proof-tool's transition verifier. + +## Delivery retries under the revised trust model + +`DeliveryRetries.tla` models one candidate-delivery slot after receipt +acceptance. Results represent complete, attempt-independent inventories, not +only the contribution binary. It distinguishes private uploaded bytes from +the coordinator's retained dispositions. Restart changes neither. + +- `delivery-retries.cfg`: bounded history, one active allocation, rejected + results cannot be accepted, acceptance is terminal, and every active + allocation can be retired without creating a replacement. +- `delivery-limit-bug.cfg`: reproduces the reviewed bug where requiring a + replacement prevents retirement at the history limit. Expected counterexample: + `ActiveCanRetire`. +- `delivery-reach-acceptance.cfg`: checks that acceptance remains reachable, + avoiding a vacuous safety result. Expected counterexample: `NeverAccepted`. + +Use the same TLC command with the selected config and `DeliveryRetries.tla`. +The small bound (3) explores branching; production protocol limits remain +separate. This is not a cryptographic model, network-provider test, or proof of +automatic progress. The implementation correspondence is proof-tool's +`AllocateDeliveryV2`, `AdvanceDeliveryV2`, and +`ValidateCheckpointTransitionV4`, with regression tests for actual history +limits and signed minimum checks before closure. The original Phase 1 model +does not yet describe the whole new role journey. + +Checked September 16, 2026 with TLC 2.19 (jar SHA-256 +`936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88`): +the corrected model explored 291 distinct states with no invariant failure. +The old mandatory-replacement model produced the expected retirement failure +at the third allocation. The reachability run produced the expected accepted +result after allocation and delivery. These results cover only the stated model. diff --git a/models/storagefirst/delivery-limit-bug.cfg b/models/storagefirst/delivery-limit-bug.cfg new file mode 100644 index 0000000..73bcc6b --- /dev/null +++ b/models/storagefirst/delivery-limit-bug.cfg @@ -0,0 +1,5 @@ +CONSTANT MaxAttempts = 3 +CONSTANT Results = {"result-a", "result-b"} +CONSTANT ForceReplacement = TRUE +SPECIFICATION Spec +INVARIANT ActiveCanRetire diff --git a/models/storagefirst/delivery-reach-acceptance.cfg b/models/storagefirst/delivery-reach-acceptance.cfg new file mode 100644 index 0000000..4393c38 --- /dev/null +++ b/models/storagefirst/delivery-reach-acceptance.cfg @@ -0,0 +1,5 @@ +CONSTANT MaxAttempts = 3 +CONSTANT Results = {"result-a", "result-b"} +CONSTANT ForceReplacement = FALSE +SPECIFICATION Spec +INVARIANT NeverAccepted diff --git a/models/storagefirst/delivery-retries.cfg b/models/storagefirst/delivery-retries.cfg new file mode 100644 index 0000000..8a18fc7 --- /dev/null +++ b/models/storagefirst/delivery-retries.cfg @@ -0,0 +1,9 @@ +CONSTANT MaxAttempts = 3 +CONSTANT Results = {"result-a", "result-b"} +CONSTANT ForceReplacement = FALSE +SPECIFICATION Spec +INVARIANT BoundedHistory +INVARIANT OneActive +INVARIANT RejectedCannotBeAccepted +INVARIANT AcceptanceIsTerminal +INVARIANT ActiveCanRetire diff --git a/models/storagefirst/phase1.cfg b/models/storagefirst/phase1.cfg new file mode 100644 index 0000000..d7c8aa9 --- /dev/null +++ b/models/storagefirst/phase1.cfg @@ -0,0 +1,7 @@ +CONSTANT Participant = "participant-1" +SPECIFICATION Spec +INVARIANT RootNamesPublishedObject +INVARIANT RootSequenceMatchesStage +INVARIANT CandidateRequiresAcceptedReceipt +INVARIANT AcceptanceRequiresUpload +INVARIANT AttemptIsPreallocated diff --git a/release/release-notes.md b/release/release-notes.md index 197871b..8d17dad 100644 --- a/release/release-notes.md +++ b/release/release-notes.md @@ -19,93 +19,77 @@ do not change any storage value. Then run the hotfix launcher's `relay ceremony recover-publication CEREMONY_NAME`; on success, resume with the original release's start script. +- The storage-first design makes the signed beacon lead configurable before + initialization in both rehearsal and production. Relay will default to 180 + seconds for rehearsals and 24 hours for production, warn explicitly before a + shorter production choice is signed, and display the additional fixed + observation window when witnesses are enabled. Existing setup contracts and + ceremonies keep their original policy. +- Added the authenticated storage-first protocol foundation: immutable signed + checkpoints, rollback/fork detection, exact submission attempts, conditional + root updates, crash-safe coordinator journals, and deterministic Phase 1 + next-action evaluation. +- Relay now consumes the proof-tool's authenticated Phase 2 and final-state + checkpoint projection as well as Phase 1; it does not parse signed checkpoint + JSON to infer those lifecycle facts itself. +- Added setup v3 and signed assurance policy. Coordinators may explicitly set + witness, mirror, ceremony-audit, and external-audit minima to zero. Disabled + roles disappear from guidance and cannot submit evidence. Future drand beacon + verification remains mandatory. +- Made the signed closure-to-beacon lead configurable for rehearsal and + production. Relay recommends 180 seconds for rehearsals and 24 hours for + production and warns before signing a shorter production value. +- Retained setup v2, setup v2 revision 2, and their existing ceremony behavior + for already pinned releases. +- Corrected V4 replacement guidance: a grant may be renewed and an immutable + upload resumed only for its original signed allocation. Retiring an + allocation requires a separately computed candidate under the replacement; + Relay preserves the old files rather than rebinding them. +- When a coordinator has transport-checked a complete candidate and proof-tool + classifies its authenticated candidate semantics as invalid, the guided + workflow now offers an explicit reviewed rejection. Relay keeps a private + receipt and rechecks every retained candidate payload byte before that option + appears and immediately before accepting or rejecting it. If an interrupted + download leaves no valid receipt, Relay preserves that local copy and fetches + the same signed attempt again into a fresh folder; it never overwrites the old + files. + Runtime, trust, path and I/O failures remain blocking rather than being + labelled invalid. Rejection preserves the received files, publishes a signed + rejection only after confirmation, and requires a fresh contribution for a + later allocation. It never rejects or replaces a candidate automatically. +- V4 retained-operation files now record and recheck both SHA-256 and + BLAKE2b-256, matching the signed protocol reference boundary. -- Reworked the ceremony flow map into seven explicit lanes covering identity - collection, enrollment and storage distribution, participant custody turns, - per-head mirror evidence, closure and beacon timing, phase transition, - finalization, signed-release authorization, upload, and archival. The map now - distinguishes public handoffs, private access, cryptographic verification, - signing, and production authorization. This is documentation-only. +The ordinary coordinator, participant, and required release-signer journeys +now derive their next action from authenticated storage state. The coordinator +must complete the full mathematical replay before final release; the required +release signer verifies the exact reviewed files and signatures but does not +repeat that replay. Existing frozen ceremonies remain on their original +schema-dispatched workflow. -- Public-file import defaults to storage settings when the ceremony set is - already present and storage is missing, instead of recommending it again. - Handoff actions show counterparts, public file locations and expected replies. - Custody delivery uses saved phase/turn packets and checks named payload hashes; - missing files cannot be reported as delivered. Human reports still do not - prove receipt or acceptance. No Tessera contract or proof-tool change. +## Validation status and current limits -- Coordinator enrollment guidance uses the latest collection check instead of - getting stuck on earlier incomplete checks. Rechecks after imports, archives - and before advancing retain validation; uncertain signing/upload recovery is - unchanged. Existing history is preserved. No proof-tool or Tessera contract - change is required. - -- Importing R2 storage settings now collects all three credential files and - saves protected copies together, avoiding the repeated-setup loop. Account - and bucket selections are retained; source files are never session-owned. - Cloud checks still require separate approval. No Tessera contract or proof-tool - change is required. -- Fix guided enrollment collection rejecting valid one-witness, one-mirror and - one-auditor requirements from the authenticated proof-tool projection. Higher - reported requirements still apply; signatures and roster checks remain required. -- Coordinators can issue recipient-bound witness/mirror setup files that assign - enrollment numbers. Observers import the file instead of typing a number. - These are unsigned instructions, not enrollments; existing signed records and - previously saved numbers remain resumable. -- Role onboarding can import the definition, signature and coordinator public - key together from one folder. It retains independent fingerprint confirmation, - authenticates the staged set with proof-tool and never replaces different files. - Individual imports remain available with their existing menu numbers. -- Standalone coordinator drafts keep optional identity import visible once the - required roster is present, without changing the recommended next step. - Beacon help explains rehearsal versus production witness lead times. -- The Bash launcher installer now disables terminal focus reporting and filters - queued focus events at every prompt, matching the CLI's input handling. -- Role setup explicitly guides public identity/enrollment delivery, requests the - coordinator's public storage file before profile creation, and prepares both - phase profiles. Participant Phase 2 preparation is recommended only when the - authenticated schedule includes them. Reports remain distinct from receipt - and verification. -- Coordinator guidance includes private grant delivery, later evidence access - and collection, and canonical production-decision/signature exchanges. -- Audit uploads stage only the exact successful report/signature pair, including - custom output paths. Receiving roles get explicit public-package instructions. -- Added a bounded onboarding model and real-menu regression for missing storage; - this is not a claim of whole-CLI formal verification. - -- Signing-container output distinguishes the enclosing ceremony role from its - network-disabled execution environment; authorization and saved profiles are - unchanged. -- Coordinator onboarding now orders inspection, sharing public ceremony files, - then enrollment collection. Sharing displays the last successfully inspected - file paths; existing task IDs and recorded progress are preserved. -- Ceremony, signature, coordinator-key and transcript-folder prompts explicitly - explain that Enter uses the saved path; coordinator-key trust guidance remains. -- Before initialization, the storage menu recommends setup when local inputs - are missing or invalid, and checks when they are present. Cloud checks and - signing still require explicit approval. -- Interactive prompts disable terminal focus reporting and ignore queued focus - events, including during hidden credential entry, so switching windows does - not corrupt answers. Confirmation phrases remain required. -- R2 setup explains why a separate inbox-only credential is required, names the - selected inbox bucket, and removes "parent" from the credential prompts. -- Standalone AWS setup is now a first-class storage menu option: review the - selected account, use existing resources or approve dedicated provisioning, - and save a protected credential snapshot. Temporary snapshots do not renew - automatically. Existing storage checks still run with separate approval. -- Installation now says "Choose your task or role" to include upload-only work. -- The beacon selection menu previews the bundled settings before selection and - distinguishes the 180-second template from production's 24-hour minimum. -- Guided onboarding and ceremony operations now retain up to 100 structured - diagnostic events per role work folder, including fixed error categories. -- Choose **E — Export bug report** in the menus, or run - `relay diagnostics export --work ROLE_WORK --out FRESH_ZIP`. -- Reports contain a short report ID, release/role/step context, operation outcomes, - OS/CPU and local Docker client versions, and expected public-file presence. - Raw terminal output, commands, environment, personal paths, keys, credentials, - profiles, and ceremony artifacts are excluded. Reports are never uploaded. -- Diagnostic logging is separate from recovery state. Exporting a report cannot - retry a command, complete a task, or overwrite an existing report. +- 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. +- 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 + requirements instead of starting an unfinishable ceremony. +- The live rehearsal uses the tiny rehearsal circuit. It does not establish + production K=21 performance, independent human operators, or physical + erasure of host or VM remnants. +- Storage-first Tessera ceremonies remain disabled until Tessera's compatible + setup-v3 and attempt-bound grant/status contract is deployed. Existing + Tessera ceremonies keep their pinned releases and behavior. ## Tessera compatibility @@ -113,6 +97,10 @@ 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 the hotfix release the ceremony runtime. No Tessera change is required. +Tessera's current grant API does not bind credentials to storage-first +checkpoint attempts. Relay must reject Tessera-backed storage-first ceremonies +until a versioned compatible grant/status contract is deployed. Existing +Tessera ceremonies and setup-v2/setup-v2r2 contracts are unchanged. Setup contracts, ceremony data, and Tessera request fields are unchanged. The ceremony-flow documentation does not change Tessera integration behavior. @@ -121,3 +109,6 @@ IDs and command-field ordering. Existing selected releases stay pinned. The additive export menu key does not renumber existing actions. No proof-tool or website change is needed for local bug-report export. Existing role folders begin recording diagnostics when opened with a compatible updated launcher. +Tessera needs its matching setup-v3 contract and policy-driven role/evidence +handling before storage-first Tessera ceremonies can be enabled. Existing +Tessera ceremonies stay pinned to their prior setup contract and Relay release. diff --git a/release/role-images.json b/release/role-images.json index 4a71c9e..3b21493 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-7ba406f0a6066f10b668ae8c553ab45e897f9fe4/mpc-ceremony", - "sha256": "bace6e72b33863edfb48c8eb8d1b50650f2a59def2f57030c9da802fb0ccbd1a" + "url": "https://github.com/zksecurity/proof-tool/releases/download/mpc-ci-0a6ec7f39527df06b1aebf8c1a60ff3a75759e89/mpc-ceremony", + "sha256": "db23d1ff01b714b7f3724747cb00567bdedbf87cc6ad88bb0efcba28e715764f" }, "linux_arm64": { - "url": "https://github.com/zksecurity/proof-tool/releases/download/mpc-ci-7ba406f0a6066f10b668ae8c553ab45e897f9fe4/mpc-ceremony-linux-arm64", - "sha256": "764038152d6f92d7949176b5755e96076481ec29f3dfb3db6c0f2d826c188e77" + "url": "https://github.com/zksecurity/proof-tool/releases/download/mpc-ci-0a6ec7f39527df06b1aebf8c1a60ff3a75759e89/mpc-ceremony-linux-arm64", + "sha256": "af3a7ff5ccd04ad51917e676442de08bf5ace6d19c97295614e9c54f9fc12b11" } } } diff --git a/scripts/relay-release-tool/main.go b/scripts/relay-release-tool/main.go index 3b2fbbf..bd1c6ce 100644 --- a/scripts/relay-release-tool/main.go +++ b/scripts/relay-release-tool/main.go @@ -687,7 +687,9 @@ func verifySBOM(path, commit string) error { var pinnedModules = []debug.Module{ {Path: "github.com/cyberphone/json-canonicalization", Version: "v0.0.0-20241213102144-19d51d7fe467", Sum: "h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q="}, {Path: "github.com/santhosh-tekuri/jsonschema/v6", Version: "v6.0.3", Sum: "h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28="}, - {Path: "golang.org/x/text", Version: "v0.14.0", Sum: "h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ="}, + {Path: "golang.org/x/crypto", Version: "v0.41.0", Sum: "h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4="}, + {Path: "golang.org/x/sys", Version: "v0.35.0", Sum: "h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI="}, + {Path: "golang.org/x/text", Version: "v0.28.0", Sum: "h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng="}, } func validatePinnedModules(deps []*debug.Module) error {